@@ -122,6 +122,11 @@ export interface McpChange {
122122
123123const UNRECORDED_SERVER_REASON = 'a server with this name already exists and is not managed by teamai' ;
124124
125+ /** The dry-run line for an unrecorded copy of a server the team removed, which a run would remove (#993). */
126+ function describeRemovedServerPreview ( target : McpTarget , name : string ) : string {
127+ return `Would remove MCP server ${ name } from ${ target . file } : it equals a server the team has removed.` ;
128+ }
129+
125130/** The dry-run line for an unrecorded server pull would record as teamai's without rewriting it (#993). */
126131function describeAdoptionPreview ( target : McpTarget , name : string ) : string {
127132 return `Would record MCP server ${ name } in ${ target . file } as teamai's: it already holds the team's ${ name } .` ;
@@ -726,13 +731,14 @@ function renderMcpEntry(
726731 return { entry : { entry, hash : entryHash ( entry ) , resolvedValue } , passthrough } ;
727732}
728733
729- /** Whether any revision of the team repo's MCP files defines a server (#993). Never for an HTTP-mode team. */
730- async function teamMcpHistoryHasServers ( localConfig : LocalConfig ) : Promise < boolean > {
731- if ( localConfig . repo . kind === 'http' ) return false ;
732- const layout = entryLayout ( 'mcp' ) ;
733- const versions = await historicalContents ( localConfig . repo . localPath , layout . dir ) ;
734- return ( versions ?? [ ] ) . some ( ( version ) => path . posix . basename ( version . path ) === layout . file
735- && ( parseTeamMcpServers ( version . content . toString ( 'utf8' ) ) ?. length ?? 0 ) > 0 ) ;
734+ /** Every revision of the team repo's MCP files (#993), or null when unreadable or an HTTP-mode team. */
735+ type TeamMcpHistory = Array < { path : string ; content : Buffer } > | null ;
736+
737+ /** The team's MCP history, read once, on first use. One per reconcile run, shared by every target. */
738+ function teamMcpHistory ( localConfig : LocalConfig ) : ( ) => Promise < TeamMcpHistory > {
739+ return once ( async ( ) => ( localConfig . repo . kind === 'http'
740+ ? null
741+ : historicalContents ( localConfig . repo . localPath , entryLayout ( 'mcp' ) . dir ) ) ) ;
736742}
737743
738744/** Whose an entry is that `target`'s own MCP record does not claim (#993). */
@@ -770,11 +776,11 @@ function judgeUnrecordedMcpEntry(
770776 desired : ReadonlyMap < string , DesiredMcpEntry > ,
771777 vars : Record < string , string > ,
772778 claimed : ReadonlySet < string > ,
779+ history : ( ) => Promise < TeamMcpHistory > = teamMcpHistory ( localConfig ) ,
773780) : McpEntryJudge {
774781 const renders = once ( async ( ) : Promise < Map < string , unknown [ ] > | null > => {
775- if ( localConfig . repo . kind === 'http' ) return null ;
776782 const layout = entryLayout ( 'mcp' ) ;
777- const versions = await historicalContents ( localConfig . repo . localPath , layout . dir ) ;
783+ const versions = await history ( ) ;
778784 if ( versions === null ) return null ;
779785 const entries = new Map < string , unknown [ ] > ( ) ;
780786 for ( const version of versions ) {
@@ -978,6 +984,14 @@ export async function unclaimedMcpServers(target: McpTarget, claimed: readonly s
978984 return unclaimed . length === 0 || ( await gitTracks ( target . file ) ) . kind === 'tracked' ? [ ] : unclaimed ;
979985}
980986
987+ /** Whether any target's file holds an MCP server, recorded or not. */
988+ async function someMcpEntryInstalled ( targets : readonly McpTarget [ ] ) : Promise < boolean > {
989+ for ( const target of targets ) {
990+ if ( ( ( await installedMcpEntries ( target ) ) ?. size ?? 0 ) > 0 ) return true ;
991+ }
992+ return false ;
993+ }
994+
981995/** `load`, run once, on the first call. */
982996function once < T > ( load : ( ) => Promise < T > ) : ( ) => Promise < T > {
983997 let value : Promise < T > | undefined ;
@@ -1833,9 +1847,10 @@ async function reconcileTargets(
18331847 // An empty desired set still has to run: it is how servers dropped from
18341848 // mcp.yaml get cleaned out of the tools we previously injected them into.
18351849 const nothingOwned = Object . values ( manifest ) . every ( ( r ) => r . length === 0 ) ;
1836- // A server the team deleted can still sit unrecorded in a tool's file (#993): only a team
1837- // history with no MCP server at all proves there is nothing to look for.
1838- if ( teamDefs . length === 0 && nothingOwned && ! await teamMcpHistoryHasServers ( localConfig ) ) return { changes, wrote } ;
1850+ // A server the team deleted can still sit unrecorded in a tool's file (#993): only target
1851+ // files with no server at all prove there is nothing to look for, without reading the history.
1852+ if ( teamDefs . length === 0 && nothingOwned && ! await someMcpEntryInstalled ( targets ) ) return { changes, wrote } ;
1853+ const history = teamMcpHistory ( localConfig ) ;
18391854 // The files an earlier pull recorded, and each record this run rebuilds after it was lost (#882).
18401855 const ledger = localConfig . scope === 'project' && ! options . dryRun ? ( await readResolvedMcpFiles ( localConfig ) ) . files : { } ;
18411856 const listed = new Set ( Object . keys ( ledger ) ) ;
@@ -1864,7 +1879,7 @@ async function reconcileTargets(
18641879 // Which of this team's servers apply to this tool, and in what rendered form.
18651880 const { desired, skipped, kept } = desiredMcpForTarget ( resolved , teamDefs , desiredContext ) ;
18661881 changes . push ( ...skipped ) ;
1867- const judge = judgeUnrecordedMcpEntry ( localConfig , resolved , desired , desiredContext . vars , claimedByOtherTools ( targets , resolved , manifest ) ) ;
1882+ const judge = judgeUnrecordedMcpEntry ( localConfig , resolved , desired , desiredContext . vars , claimedByOtherTools ( targets , resolved , manifest ) , history ) ;
18681883 // A file an earlier teamai created that hides a later one, holding only teamai's servers, is left (#993).
18691884 const leaving = removeAll ? null : await leaveFormerMcpFile ( resolved , manifest [ manifestKey ] ?? [ ] , judge ) ;
18701885 const target = leaving ? { ...resolved , file : leaving . next } : resolved ;
@@ -2147,6 +2162,7 @@ async function applyJson(
21472162 // server from the team history (#993), and goes like any other server teamai no longer delivers.
21482163 for ( const [ name , entry ] of Object . entries ( doc . servers ) ) {
21492164 if ( desired . has ( name ) || ownedNames . has ( name ) || await judge ( name , entry ) !== 'teamai' ) continue ;
2165+ if ( options . dryRun ) log . info ( describeRemovedServerPreview ( target , name ) ) ;
21502166 delete doc . servers [ name ] ;
21512167 dirty = true ;
21522168 changes . push ( { tool : target . tool , server : name , action : 'removed' } ) ;
@@ -2336,6 +2352,7 @@ async function applyCodex(
23362352 if ( desired . has ( name ) || ownedNames . has ( name ) || await judge ( name , codexBlockIn ( source , name ) ) !== 'teamai' ) continue ;
23372353 const next = spliceCodexBlock ( source , name , null ) ;
23382354 if ( next === source ) continue ;
2355+ if ( options . dryRun ) log . info ( describeRemovedServerPreview ( target , name ) ) ;
23392356 source = next ;
23402357 dirty = true ;
23412358 changes . push ( { tool : target . tool , server : name , action : 'removed' } ) ;
0 commit comments