Skip to content

feat(mcp): keep project MCP configs with resolved tokens out of git (#882) - #886

Merged
jeff-r2026 merged 92 commits into
Tencent:mainfrom
SaulMoro:feat/882-mcp-git-exclude
Sep 30, 2026
Merged

jeff-r2026 merged 92 commits into
Tencent:mainfrom
SaulMoro:feat/882-mcp-git-exclude

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A project MCP config that holds a resolved token is kept out of git through the clone's own .git/info/exclude. Nothing committed changes.

Most of this landed with #880: it was stacked on 2033b5be here, and its squash (7c929a99) carries the exclude block, uninstall, doctor's check and managed-mcp-files.json. See #880 for that design. Against main, this PR is the follow-up fixes after 2033b5be. They close three gaps:

 local agent (HTTP-backed team), install_mcp into a project config
+  server carries a credential (header, env value, argument, any URL, command line with arguments)
+    → list the file in .git/info/exclude before writing; tracked or unlistable → nothing written,
+      install fails with the reason ("install the MCP server again")
+  new install: Git preflight → provisional ownership → exclusion + file record → MCP write
+    initial ownership write fails → MCP config and exclusion unchanged
+  existing JSON update: retain prior ownership → MCP write → completed ownership
+    config write fails → prior record unchanged; later manifest failure → restore prior config
+  uninstall: remove the entry → drop ownership; read/write failure retains ownership
+    later manifest failure → restore the entry
+  failed reconcile before ownership is saved → restore every written config, once per shared file
+    failed restoration → report both failures; keep files with credentials excluded
+  next sync or pull in the workspace, also after an uninstall_teamai that failed or kept shared files
+    → list a file an older local agent wrote a credential into, and record it
+  a record's resolved: false counts only while the entry on disk has its hash (a failed write leaves the old one)
+  also read CodeBuddy's pre-57636a27 default .codebuddy/mcp.json (no teamai.yaml history to name it)
+  each entry judged on its own: a Copilot bare entry apart from the one of its name under mcpServers
 doctor
+  HTTP-backed team → check those configs too (✖ while one would commit)
 Copilot project config that another tool wrote mcpServers into
+  read the bare top-level servers beside mcpServers (protection, cleanup, doctor)
+  Copilot writes a server again under mcpServers → remove teamai's own bare copy (completed bare write + hash match)
+  a bare ownership record never authorizes updating/removing a member's mcpServers entry of the same name
+  an unmarked Copilot record needs a matching keyed hash, without an identical bare match
+  completed keyed writes persist bare: false; a failed placement write remains unproven
+  a moved Copilot's old file: a bare server counts as owned only by a Copilot record, never by a name
+    another tool owns under mcpServers
 tool the team moved, its old file now mapped by another tool
+  judged by the records of the tools that read the same key; a lost record's rebuild claims by key
src/local-agent.ts       install_mcp exclusion before the write, sync-time listing
src/mcp-reconcile.ts     Copilot bare servers beside mcpServers, bare-copy removal, key-scoped claims
src/mcp-git-exclude.ts   carriesLocalAgentCredential; callers name their own retry in the fix
src/doctor-delivery.ts   HTTP-team configs, judged by the local agent's records

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit passes
  • npm run lint passes
  • npx vitest run passes (352 files, 6145 passed, 1 skipped at d548eaa2; CLAUDE_CONFIG_DIR unset, see fix(tests): prevent model tests from overwriting Claude config (P1) #890)
  • Added/updated tests for the change. All four regressions from the previous review fail against 9c3333d9 and pass after the fix.
  • npm run test:e2e: 391 passed, 26 skipped
local agent   header / env value install, git checkout → listed before the write            was: written, committable
              tracked config → withheld, reason named, file and records untouched           was: written
              bare stdio command → no line                                                  guard
              argument, any URL, OpenCode's command array → a credential                    was: only headers and env
              older install's credential file → listed on the next sync, no command needed  was: never listed
              sync with a failed uninstall_teamai → still listed                            was: skipped
              sync cannot list the file → says the next session tries again                 was: said to pull
              install of a bare command over an older credential entry, write fails → the
              next sync still lists the file                                                was: trusted resolved: false, committable
              credential at CodeBuddy's former .codebuddy/mcp.json → listed on sync; doctor ✖  was: never visited
              Copilot bare entry with a credential beside a credential-free mcpServers entry of
              its name → listed                                                             was: hidden by the merge
pull          HTTP team, older local agent's credential file → listed, recorded; dry run writes nothing   was: never listed
doctor        HTTP team: ✔ once ignored; ✖ for a listed file with no record, or a credential server's
              record lost while another's remains; nothing for a server without credentials was: skipped
Copilot bare  line kept while the bare entry holds the value beside Claude's mcpServers     was: hidden
              written again under mcpServers → bare copy removed                            was: stale token left beside it
              a member's own bare server of the same name → never removed                   was: deleted
              stale bare copy that differs from mcpServers' entry → line kept               guard
              team drops it → bare entry removed, Claude's mcpServers left                  was: left
              local agent: install again │ uninstall_mcp after another tool wrote mcpServers → bare entry replaced │ removed   was: left
moved + key   the tool mapping a moved tool's file today never claims its stale server under
              another key; doctor ✖ for it                                                  was: claimed, released
moved Copilot its bare stale server beside Claude's mcpServers entry of the same name → pull
              lists the file; doctor ✖                                                      was: claimed by name, released

Real CLI after the merge (npm run build, temporary HOME, CLAUDE_CONFIG_DIR unset, project scope, toolPaths puts Copilot and Claude on .mcp.json, jira for Copilot with Bearer ${JIRA_TOKEN}, open for Claude):

1. empty .mcp.json, pull           .mcp.json: { jira }  (bare, token)
2. team adds open, pull            .mcp.json: { mcpServers: { open, jira } }  bare jira gone, one copy of the token
3. jira made a literal, token unset, pull   token count 0
   .git/info/exclude               [teamai:mcp-exclude:start] /.mcp.json [end]; git status: .mcp.json not listed
   doctor                          ✔ Project MCP configs with resolved values are kept out of git

Real CLI, a moved Copilot (same setup, pre-fix build 7ffd5593 against 17b1a59d): Copilot on .mcp.json writes a bare jira with the token; the team moves Copilot to .github/mcp.json and gives jira to Claude as a literal; token unset, exclude emptied, pull.

                     7ffd5593                        17b1a59d
.mcp.json            bare jira (token) + mcpServers.jira (literal)
.git/info/exclude    (none)                          /.mcp.json
git status           ?? .mcp.json  ← token           (not listed)
doctor               no check                        ✔ … kept out of git

A second run of the base flow (Claude, project scope) also passed: pull, a second pull, doctor ✔, then uninstall --force removed the token and the block. Real CLI, local agent (hook-dispatch session-start against a local fake HTTP backend; install_mcp with a Bearer header, then the files left as an agent from before this PR left them: exclude emptied, no managed-mcp-files.json, no resolved note; one more sync). Pre-fix build 17b1a59d against 9c3333d9:

                                               17b1a59d                          9c3333d9
CodeBuddy, token at .codebuddy/mcp.json        exclude: none, ?? .codebuddy/…    /.codebuddy/mcp.json, not listed
Copilot, bare token + credential-free
  mcpServers entry of its name, .github/…      exclude: none, ?? .github/…       /.github/mcp.json, not listed

The local agent's failed-write case (a read-only directory) is covered by its unit test only.

Review verification at 4e71c7a6

 Copilot bare-copy cleanup
-  ownership name + content hash
+  ownership name + completed bare write + content hash
 HTTP install_mcp
-  exclusion + file record → ownership manifest → MCP config
+  Git preflight → ownership manifest → exclusion + file record → MCP config
+  record bare placement only after the MCP write succeeds
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 260 passed. Covers an identical member entry through pull/removal and install/update/uninstall, legacy records without placement evidence, and a failed ownership write leaving the user config visible to Git.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Full npx vitest run: 352 files, 6087 passed, 1 skipped. The first run alongside other checks had six failures in push-env.test.ts; its isolated run passed all 19, as did the same file on 9c3333d9. Repeating the full suite without concurrent checks passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record above remains from 9c3333d9; the full provider/agent suite was not rerun for this follow-up.
  • Real CLI after the build, using hook-dispatch session-start --tool copilot against a fixture HTTP backend: install, update and uninstall preserve an identical member-owned bare jira; a directory at managed-mcp.json makes install fail, while the config and exclusion stay unchanged, no sidecar appears, and git status still lists ?? .github/mcp.json. Harness: /tmp/882-r6-cli.mjs.

Review verification at 1a4ddfa1

 TeamAI owns bare jira; member adds mcpServers.jira
-  bare record's name authorizes changing both placements
+  update skips the unmanaged keyed collision and retains bare ownership
+  remove/uninstall deletes only the matching owned bare copy
+  missing-secret retention keeps the bare record without claiming the keyed entry
  • Five regressions fail before this fix at 4e71c7a6: pull update, dropping a server, removeAll, local-agent install/update, and local-agent uninstall. A sixth guard covers missing-secret retention. All six pass after the fix, including a repeated pull after a skipped update.
  • Full npx vitest run: 352 files, 6093 passed, 1 skipped.
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 266 passed.
  • npm run build, npx tsc --noEmit, npm run lint: passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • Real CLI after the build, hook-dispatch session-start --tool copilot against a fixture HTTP backend: initial bare TeamAI install succeeds; the member then adds a same-named keyed server; two update commands fail with not managed by teamai and preserve both entries; uninstall succeeds and removes only TeamAI's bare copy. Harness: /tmp/882-r7-cli.mjs.
  • git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe. The existing branch commits all belong to [feat] keep project MCP configs with resolved tokens out of git #882.

Review verification at c0748a9b

 Copilot project ownership
-  missing bare: true → keyed ownership by name
+  bare: true  → bare placement
+  bare: false → keyed placement
+  unmarked    → matching entry hash, with no equal bare match for a keyed entry
 local-agent install
+  preserve a prior proven placement while updating that same placement
+  record either placement after the config write succeeds
+  failure of the placement-record write leaves unknown placement
  • 13 regressions fail against 1a4ddfa1 and pass after the fix: legacy bare records and a failed post-write manifest update, each with distinct or identical keyed member content, exercised through pull/update/remove and local-agent install/uninstall; plus compatibility for a legacy keyed record with a matching hash.
  • Full npx vitest run: 352 files, 6106 passed, 1 skipped.
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 279 passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Real CLI after the build, hook-dispatch session-start --tool copilot against a fixture HTTP backend: a legacy unmarked bare record and an injected failure on the second atomic ownership-manifest rename both reject repeated updates and preserve the member's keyed entry on uninstall. The failure case uses identical bare/keyed content. A genuine legacy keyed entry with a unique matching hash still updates, records bare: false, and uninstalls. Harness: /tmp/882-r8-cli.mjs, fault injection: /tmp/882-r8-fault.mjs.
  • git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe. Every branch commit belongs to [feat] keep project MCP configs with resolved tokens out of git #882.

Latest review verification at d548eaa2

 existing JSON local-agent update
-  replace ownership -> write config
+  keep prior ownership -> write config -> save completed ownership
+    config write failure   -> prior record stays valid for retry/removal
+    manifest write failure -> restore prior config
 uninstall_mcp
-  drop ownership -> remove entry; an unreadable JSON config reports success
+  read/remove entry -> drop ownership; read/write failures keep ownership
+    manifest write failure -> restore entry
 MCP reconcile
+  snapshot each config once before any tool writes it
+  failure before ownership commit -> restore all written configs
+  retain newly added file records only after ownership is committed
 Codex config reads
-  read error -> absent file
+  propagate read error; preserve config and ownership
  • 39 additional regression cases cover proven bare/keyed and legacy records, bare-to-keyed migration, failed config/manifest writes, retry and uninstall, invalid JSON, Codex read failures, a second tool failing on a shared config, and restoration failures. Before repair, fault injection reproduced 25 local-agent failures, 5 reconcile failures and 2 Codex read failures.
  • Full npx vitest run: 352 files, 6145 passed, 1 skipped. MCP targeted suite: 420 passed; final local-agent suite after snapshot simplification: 76 passed.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • Real CLI after the final build: hook-dispatch session-start --tool copilot, isolated HOME/Git workspace, fixture HTTP backend. Tested a legacy bare update, a proven bare-to-keyed update and uninstall. For each, injected an atomic config-rename failure, then a post-config ownership-manifest rename failure. Both failures reported failed, preserved the exact prior config/ownership, and left the credential file absent from Git status. Retry and subsequent uninstall succeeded; the old token was removed and the member's keyed server survived. Harness: /tmp/882-r9-cli.mjs; rename-fault preload: /tmp/882-r9-fault.mjs.
  • Reviewed the accumulated branch diff and listed readers/writers of MCP configs, ownership, resolved-file records and Git exclusions. git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe; no unrelated branch commits.
  • Recovery is covered for caught filesystem failures. If restoration itself fails, the command names both causes and requires repair of config/ownership; the remaining credential stays excluded. This change does not add a multi-file crash-recovery protocol.

Related Issues

Closes #882. Found while implementing #879 / #880.

Notes for Reviewers

Door: two-way. Only writes a marked block in a local, uncommitted file; uninstall removes it.

Blast Radius: MCP configs. HTTP-backed teams using local-agent install/uninstall, JSON/TOML reconcile failure recovery, and Copilot project configs shared with another tool.

  • Design: any credential counts for the local agent. Its payload carries literal values, not ${VAR}, so there is no definition to judge against. Only a bare stdio command is treated as credential-free.
  • Design: remove only what teamai wrote. A bare Copilot copy goes only when its record proves a completed bare write and its hash matches. Identical content in a member-owned bare entry proves no ownership. Old records without placement evidence leave that bare entry alone; an unchanged pull does not infer placement. A bare record cannot claim the keyed entry. A skipped update retains the bare ownership record so later cleanup still reaches the owned copy. Copilot records distinguish completed bare writes (bare: true), completed keyed writes (bare: false), and unknown placement. An unknown record needs a matching hash in the keyed entry and no equal hash in the bare entry; an identical duplicate is ambiguous and left alone.
  • Merge: the merge with main used 2033b5be as its base. One real conflict came out of it, in applyJsonTarget: feat(env): team-declared secrets with member-local values (#875) #880's holdsResolvedValue and this branch's bare-copy removal are both kept.

Known limits

  • For an HTTP-backed team, only teamai uninstall takes the line out: no pull or teamai mcp remove releases it. An older install's file is listed only in the current workspace; other workspaces are covered at their next session.
  • In a Copilot project config that also holds mcpServers, every other top-level object reads as a bare server. An object-valued setting there reads as a server no record claims, which keeps the line.

…encent#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 Tencent#880.
…Tencent#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".
@jeff-r2026 jeff-r2026 self-assigned this Sep 28, 2026
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/uninstall.ts:1251 removes the ignore block even when MCP entries were not removed. For example, applyJson skips an invalid .mcp.json without throwing; uninstall then exposes the still-present plaintext token to git add -A. Remove exclusions only after confirming every relevant config was cleaned successfully.
  • [P2 non-blocking] src/doctor-delivery.ts:526 infers a resolved secret from server names alone. A user-owned server colliding with a team server name is skipped by reconciliation, but doctor incorrectly claims it contains TeamAI-resolved plaintext and recommends teamai pull, which cannot fix the warning. Compare the installed entry with the desired entry or ownership manifest.
  • [P3 nit] src/mcp-git-exclude.ts:84 appends a new block when an existing start marker has lost its end marker. A later uninstall pairs the original start with the newly appended end and deletes any user exclusions between them, contrary to the damaged-block safeguard.
  • [P3 nit] src/uninstall.ts:1252 only removes the block from the project root repository, although excludeFromGit explicitly supports MCP paths inside submodules/nested repositories. Such configurations leave TeamAI’s block behind after uninstall.

The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…cent#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
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/uninstall.ts:1270 treats the absence of leftInPlace as proof that every protected config was cleaned. If managed-mcp.json is missing or unreadable, reconcileMcpForConfig returns early with no ownership records, leaving a resolved token in .mcp.json; uninstall then removes the exclude block, exposing that token to git add -A. Keep the block unless every protected path was positively inspected and cleaned.

The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…en clean (Tencent#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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:568 skips excluded/disabled tools before applying the new Git protection. A project MCP file containing a plaintext token from an earlier pull remains on disk after its tool is disabled, but subsequent pulls never add it to .git/info/exclude; src/doctor-delivery.ts:527 skips the same target, so doctor also reports nothing. git add -A can commit the token. Existing owned entries must still be protected and diagnosed even when delivery is disabled.
  • [P2 non-blocking] src/mcp-git-exclude.ts:61 treats every git check-ignore failure as “Git would not track this file.” In a valid repository where Git returns 128—for example due to unsafe-repository configuration—the plaintext config is neither excluded nor warned about. Preserve an error state and warn rather than silently treating it as safe.
  • [P3 nit] src/uninstall.ts:539 proves cleanliness using only the team’s current MCP definitions. If a server was removed from mcp.yaml and its ownership manifest was also lost, its old plaintext entry is considered clean and the exclusion block is removed. This requires two failures, but exposes the remaining value.

The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…encent#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
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:549 only recognizes owned entries whose server still exists in the current teamDefs. Concrete failure: an older pull writes a resolved jira token without an exclude block, the team later removes jira from mcp.yaml, and cleanup is skipped because .mcp.json is invalid or the tool is disabled. The token remains, but the protection pass adds no exclusion, so git add -A can commit it. src/doctor-delivery.ts:537 has the same false negative.
  • [P2 non-blocking] src/doctor-delivery.ts:519 returns without checking Git whenever the team MCP definitions cannot be resolved. With malformed mcp.yaml, a manifest-owned plaintext config that is tracked or no longer excluded receives only the generic parsing failure; doctor omits the specific credential-exposure warning even though pull conservatively treats that owned entry as sensitive.

The previously reported findings are resolved in the current diff. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…encent#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
@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/uninstall.ts:553 cannot recognize configs in linked worktrees of a nested repository. findMcpGitExcludes expands the nested repository’s patterns across all its worktrees, but targets only covers worktrees of the outer project repository. Consequently, an existing but clean MCP config in a nested repo’s linked worktree is classified as “no tool teamai knows reads it,” so uninstall permanently retains an otherwise removable exclude block.
  • [P3 nit] src/mcp-git-exclude.ts:173 abandons serialization after 2.5 seconds and performs the read-modify-write without owning the lock. If another process remains paused or its filesystem operation exceeds that timeout, concurrent additions/removal can overwrite each other and lose a protected path. Waiting longer or failing safely would preserve the claimed locking guarantee.

The previously reported findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 28, 2026
…esolved tokens out of git

# Conflicts:
#	src/doctor-delivery.ts
#	src/mcp-reconcile.ts
…s its lock (Tencent#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.
… free of teamai's servers (Tencent#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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:527 judges owned entries using only the current server definition. If an earlier pull wrote jira from ${TOKEN}, the tool is later disabled, and the team changes jira to a definition without variables, the stale plaintext token is no longer recognized; pull adds no exclusion and doctor reports nothing, so git add -A can commit it.
  • [P1 blocking] src/mcp-reconcile.ts:234 only revisits current mappings and built-in defaults. If a previous pull wrote a token to a custom mcpProject path and the team later changes or removes that mapping before the member upgrades, the old file is never inspected or excluded and can be committed.
  • [P1 blocking] src/uninstall.ts:542 treats the existence of any manifest file as proof for every MCP target. If one target’s ownership record is missing while another target still has records—or cleanup writes an empty {} manifest—a valid file containing a dropped server and an unset token is considered clean at src/uninstall.ts:574; uninstall removes its exclude line and exposes the plaintext value.
  • [P2 non-blocking] src/uninstall.ts:544 still cannot associate a nested repository’s linked-worktree files with an MCP target. A clean mcp.json in that linked worktree is classified as “no tool teamai knows reads it,” so uninstall retains the shared exclusion indefinitely. This previously reported finding remains unresolved.

The other earlier findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…proof, skip unlocked writes

# Conflicts:
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
…lved value into it (Tencent#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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:525 treats an owned server as safe when its current definition keeps the same name but removes ${VAR}. If an earlier pull wrote a token and the tool is subsequently disabled, dropped and needing are both false and the value scan has no referenced variable to inspect; pull and doctor skip the file, allowing git add -A to commit the stale token.
  • [P1 blocking] src/mcp-reconcile.ts:234 still enumerates only current mappings and built-in defaults. If an earlier pull wrote a token to a custom mcpProject path and the team later changes or removes that mapping, the old file is never inspected or excluded, so it remains commit-able.
  • [P1 blocking] src/uninstall.ts:542 considers the existence of any manifest file proof for every target in that worktree. With an empty {} manifest—or records only for another target—a config containing a dropped server and an unset token reaches src/uninstall.ts:574 as clean; uninstall removes its exclude entry and exposes the plaintext value.
  • [P2 non-blocking] docs/usage-guide.md:1163 documents the runtime behavior, but the affected agent-facing MCP and uninstall guidance under skill-data/setup/references/ remains unchanged, contrary to the repository rule requiring behavior changes to update affected skills.

The earlier nested-repository linked-worktree issue and the other previously resolved findings remain fixed. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…ed value

Tencent#882's pre-write exclusion is now the single gate for a project MCP config
git would commit. Tencent#880's separate tracked-file check (gitTracks in
buildDesiredMcpContext, `withheld` in desiredMcpForTarget, its warning,
mcp list lines and doctor note) is removed: a tracked file is one more way
the exclusion fails, reported once with `git rm --cached <file>` and rotate.
A dry run (mcp list, doctor) now also names a tracked file before any pull
has listed it, mcp list reports a withheld file with an entry already
installed, and doctor reports withheld servers without the pull --force text.
A tracked file now gets no resolved value, declared secret or not.
…ready installed (Tencent#882)

Backports Tencent#880's merge 0d9f7fa: 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.
…clude (Tencent#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.
…olds no resolved value (Tencent#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.
…re the pull rewrote it (Tencent#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.
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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:535 treats an owned entry as safe when its same-named definition stops referencing ${VAR}. If a disabled tool retains the old plaintext token, neither dropped, needing, nor the current-definition value scan detects it, so the file remains commit-able.
  • [P1 blocking] src/mcp-reconcile.ts:245 visits only current mappings and built-in defaults. If a team replaces or removes a custom mcpProject path, the previously written file is never inspected or excluded, allowing its resolved token to be committed.
  • [P1 blocking] src/mcp-git-exclude.ts:180 checks whether the file is tracked only when check-ignore returns would-commit. If check-ignore errors while rev-parse still locates the repository, a tracked file gets an exclude line and the second error is accepted as success at src/mcp-git-exclude.ts:215; reconciliation can then write plaintext into a file that Git already tracks.
  • [P2 non-blocking] src/mcp-reconcile.ts:593 still cannot associate a nested repository’s linked-worktree files—added by src/mcp-git-exclude.ts:283—with MCP targets. Even a missing-token, clean config is classified as unreadable by any known tool, so uninstall cannot remove its shared exclusion.
  • [P2 non-blocking] src/mcp-cmd.ts:79 reports a server as “withheld” for every target requiring resolution without applying its tools, transport, policy, exclusion, or requirements filters. For example, a Cursor-only server can incorrectly be reported as withheld from Claude.
  • [P2 non-blocking] The runtime behavior is documented only in docs/usage-guide.*; the affected agent-facing MCP/uninstall guidance under skill-data/setup/references/ remains unchanged, contrary to the repository rule requiring behavior changes to update affected skills.

The other previously fixed findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…holds a server (Tencent#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.
…and command line as credentials, and scope the rebuild's claims by key (Tencent#882)
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-git-exclude.ts:55 treats only URL credentials in the user-info or query string as sensitive. An HTTP install_mcp payload such as https://example.com/mcp/<token> has a plaintext credential in its path, but carriesLocalAgentCredential returns false, so the project config is written without an exclusion and git add -A can commit it.
  • [P1 blocking] src/doctor-delivery.ts:611 checks HTTP-team configs only through managed-mcp.json records. If that manifest is missing or unreadable, an existing project config containing a credential is ignored even when managed-mcp-files.json records the file; if its exclusion is also absent, doctor reports no failure and the credential remains commit-able. Inspect recorded/current files conservatively when ownership records are unavailable.
  • [P3 nit] src/local-agent.ts:2976 and src/doctor-delivery.ts:616 still describe protected data only as “header or env values,” although arguments, command lines, and authenticated URLs now trigger protection too.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

… have doctor judge a recorded HTTP-team file with no record (Tencent#882)
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-git-exclude.ts:55 misses OpenCode command lines because OpenCode renders command: "server --token secret" as the one-element array ["server --token secret"]. The array-length check returns false, so a project install writes the plaintext token without an exclusion and git add -A can commit it.
  • [P1 blocking] src/mcp-reconcile.ts:1597 assumes every same-named bare Copilot entry is an old TeamAI copy. If a member owns a top-level jira and another tool has added mcpServers, the first pull writes TeamAI’s nested jira; the next pull or removal deletes the member’s bare entry. src/local-agent.ts:2949 and src/local-agent.ts:3047 have the same destructive behavior.
  • [P1 blocking] src/mcp-reconcile.ts:606 collapses same-named bare and nested Copilot entries, with the nested value winning. An older pull can leave a tokenized bare entry beside a newer literal nested entry; when Copilot is disabled and the token is unset, protection sees only the literal entry and can remove the exclusion, exposing the stale token to git add -A.
  • [P1 blocking] src/doctor-delivery.ts:615 handles a completely missing HTTP-team tool record but not a partially lost record. If a file contains credential server A and bare server B, A’s manifest record is lost while B’s remains, and the exclusion is absent, doctor checks only B’s resolved: false record and reports no exposure although A remains commit-able.

The other previously reported findings appear resolved. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…e Copilot copy, hold a shadowed one, and judge a partly lost HTTP record by its entries (Tencent#882)
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1153 skips the protection pass for HTTP-backed teams, while src/local-agent.ts:2966 adds an exclusion only during a fresh install_mcp command. Concrete failure: a pre-upgrade local-agent installation already left a credential in a project MCP config, then routine sync sends no new install command; upgrading or running teamai pull never excludes the existing file, so git add -A can commit the credential. Doctor only reports the exposure. Add an HTTP migration/protection pass over existing recorded/current project configs.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:3275 sets uninstalling merely because the response contains an uninstall_teamai command, so src/local-agent.ts:3293 skips credential protection even if that command fails or performs a non-last-agent uninstall that intentionally leaves shared MCP files intact. A pre-upgrade, unexcluded project MCP credential therefore remains visible to git add -A after this sync. Skip protection only after a successful uninstall that actually removes shared resources.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…amai too: a failed or partial one leaves what to keep out of git (Tencent#882)
@github-actions

Copy link
Copy Markdown

Review Result

  • No findings in the current diff.
  • The previously reported uninstall_teamai protection issue is resolved: workspace MCP protection now runs after successful, failed, or partial uninstall commands.
  • The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Please resolve the conflicts

# Conflicts:
#	docs/designs/data-directory-layout.md
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
#	skill-data/setup/references/manage-admin.md
#	src/__tests__/doctor-mcp-delivery.test.ts
#	src/__tests__/mcp-cmd.test.ts
#	src/__tests__/mcp-git-exclude.test.ts
#	src/__tests__/mcp-reconcile.test.ts
#	src/doctor-delivery.ts
#	src/mcp-cmd.ts
#	src/mcp-git-exclude.ts
#	src/mcp-reconcile.ts
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:2950 persists resolved: false before writing the replacement config. If an older, unexcluded credential-bearing entry is being replaced with a bare command and writeJsonDoc then fails—for example because the config directory is read-only—the old credential remains, but localAgentCredentialFiles trusts the false flag and skips its on-disk credential check. The post-sync migration therefore leaves the plaintext file commit-able. Persist the manifest only after the config write succeeds, or verify the recorded hash before trusting resolved: false.
  • [P1 blocking] src/mcp-reconcile.ts:920 reduces a historical Copilot file to server names before comparing it with current owners. If Copilot previously wrote a tokenized bare jira, later moves elsewhere, and Claude now owns a different nested mcpServers.jira in that file, the merged name is considered owned and the stale bare token is missed. Pull, doctor, or uninstall can then release or omit the exclusion. Historical-file evidence must preserve and inspect Copilot’s shadowed bare entry rather than merging it by name.

The previously reported findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…lds, not by the new install's resolved: false (Tencent#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.
…another tool owns under mcpServers (Tencent#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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1346 removes every unmapped built-in fallback before the HTTP migration scan. A pre-57636a27 local agent can have written a credential to CodeBuddy’s former .codebuddy/mcp.json without managed-mcp-files.json; after upgrading, only the current .mcp.json target is inspected, leaving the old credential file visible to git add -A. Include known historical defaults in the local-agent migration scan.
  • [P1 blocking] src/mcp-reconcile.ts:657 merges Copilot’s bare entries with mcpServers, with the nested entry winning on duplicate names. If an older Copilot bare jira contains a credential and another tool later writes a credential-free mcpServers.jira, localAgentCredentialFiles examines only the nested value; the existing Copilot record also suppresses the unrecorded-entry scan, so sync and doctor miss the bare credential and leave it commit-able. Inspect bare and keyed entries separately for HTTP-team protection.

The two findings from the September 30 review are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…efault and a bare Copilot entry apart (Tencent#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 57636a2 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).
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1785 identifies a historical TeamAI bare Copilot entry solely by matching its hash against the current ownership record. Concrete failure: a member already has a top-level jira identical to the team definition, TeamAI installs the same server under mcpServers, and a later pull/update/remove sees the matching hash and deletes the member-owned top-level entry. The record must also prove TeamAI originally wrote the bare placement.
  • [P2 non-blocking] src/local-agent.ts:2951 writes the Git exclusion and sidecar before persisting the ownership manifest. If that manifest write fails, no MCP entry is written, but the exclusion remains; HTTP mode never releases these lines, and uninstall cannot remove it without the ownership record. This can permanently hide an otherwise user-owned project MCP config from Git after a failed install.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1816 and src/local-agent.ts:2943 still apply bare Copilot ownership to the keyed mcpServers entry by name. Concrete failure: TeamAI previously wrote bare jira, then a member adds their own mcpServers.jira; a later update overwrites the member entry, and dropping/uninstalling jira deletes it. Scope ownership checks to the recorded bare placement before modifying the keyed entry.

The previously reported findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1790 treats every ownership record without bare: true as ownership of the keyed mcpServers entry. Legacy Copilot bare records lack this new field, as does a record left if the post-write manifest update at src/local-agent.ts:2961 fails. If a member later adds a same-named keyed server, pull overwrites it at src/mcp-reconcile.ts:1843, while local-agent install/uninstall can overwrite or delete it through src/local-agent.ts:2944 and src/local-agent.ts:3060. Require positive keyed-placement evidence—such as a matching keyed hash—before an unmarked record can claim that entry.

The other previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:2953 replaces an existing Copilot ownership record with the new hash and potentially removes its placement before writeJsonDoc succeeds. If updating a proven bare or legacy entry fails while writing the config, the old entry remains but no longer matches the manifest. A later uninstall_mcp reports success while leaving TeamAI’s old credential-bearing entry behind, and a retry can create a keyed copy without removing the bare one. Preserve the prior record until the config write succeeds, or restore it on failure.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/local-agent.ts:2961 does not roll back a newly added Git exclusion when an update subsequently fails. For example, updating an existing credential-free managed server to a URL-bearing entry adds the exclusion, but a read-only config directory makes writeJsonDoc fail; the old clean config remains silently ignored until full uninstall because HTTP protection only adds exclusions. Remove the newly added exclusion when the config is unchanged/restored.

The previously reported ownership-loss issue is resolved: failed updates retain the prior manifest record and restore the prior config.

The PR description documents sufficient unit, e2e, and representative real-CLI testing. Per instruction, I did not execute tests or PR code.

@jeff-r2026
jeff-r2026 merged commit fb9f5a2 into Tencent:main Sep 30, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] keep project MCP configs with resolved tokens out of git

2 participants