@@ -11,6 +11,7 @@ import (
1111 "os"
1212 "os/exec"
1313 "path/filepath"
14+ "reflect"
1415 "sort"
1516 "strconv"
1617 "strings"
@@ -1056,12 +1057,12 @@ func cmdShare(args []string) error {
10561057 }
10571058 userHome , _ := os .UserHomeDir ()
10581059 sourceClaudeJSON := filepath .Join (userHome , ".claude.json" )
1059- synced , err := syncMCP (dir , sourceClaudeJSON )
1060+ res , err := syncMCP (dir , sourceClaudeJSON , cfg . SyncedMCP , false )
10601061 if err != nil {
10611062 fmt .Fprintf (os .Stderr , " mcp — sync error: %v\n " , err )
1062- } else if len ( synced ) > 0 {
1063- cfg .SyncedMCP = append ( cfg . SyncedMCP , synced ... )
1064- fmt .Printf (" mcp — synced %d server(s) from global \n " , len ( synced ))
1063+ } else if res . changed {
1064+ cfg .SyncedMCP = res . synced
1065+ fmt .Printf (" mcp — %s \n " , formatMCPReconcile ( res ))
10651066 } else {
10661067 fmt .Printf (" mcp — shared (no new servers to sync)\n " )
10671068 }
@@ -3148,11 +3149,13 @@ func cmdSync(args []string) error {
31483149 }
31493150
31503151 var names []string
3151- var noPlugins , noMCP , noInherit , noSettings , noAuth bool
3152+ var noPlugins , noMCP , noInherit , noSettings , noAuth , dryRun bool
31523153 var fromRig string
31533154 for i := 0 ; i < len (args ); i ++ {
31543155 a := args [i ]
31553156 switch {
3157+ case a == "--dry-run" :
3158+ dryRun = true
31563159 case a == "--no-plugins" :
31573160 noPlugins = true
31583161 case a == "--no-mcp" :
@@ -3228,10 +3231,43 @@ func cmdSync(args []string) error {
32283231 authSourceDir = fromDir
32293232 }
32303233
3234+ if dryRun {
3235+ fmt .Println ("Dry run — previewing MCP reconciliation (no changes written):" )
3236+ }
3237+
32313238 for _ , name := range names {
32323239 dir , _ := rigDir (name )
32333240 cfg := loadRigConfig (dir )
32343241
3242+ // Dry run only previews the MCP reconcile — the step that mutates
3243+ // existing config — and skips every write.
3244+ if dryRun {
3245+ if noMCP || cfg .isIsolated ("mcp" ) {
3246+ fmt .Printf (" %s — MCP sync skipped\n " , name )
3247+ continue
3248+ }
3249+ res , err := syncMCP (dir , sourceClaudeJSON , cfg .SyncedMCP , true )
3250+ if err != nil {
3251+ fmt .Fprintf (os .Stderr , " %s: MCP sync error: %v\n " , name , err )
3252+ continue
3253+ }
3254+ if ! res .changed {
3255+ fmt .Printf (" %s — MCP up to date\n " , name )
3256+ continue
3257+ }
3258+ fmt .Printf (" %s — MCP would: %s\n " , name , formatMCPReconcile (res ))
3259+ for _ , n := range res .added {
3260+ fmt .Printf (" + %s\n " , n )
3261+ }
3262+ for _ , n := range res .updated {
3263+ fmt .Printf (" ~ %s\n " , n )
3264+ }
3265+ for _ , n := range res .removed {
3266+ fmt .Printf (" - %s\n " , n )
3267+ }
3268+ continue
3269+ }
3270+
32353271 // 0. Auth files — re-link if already symlinked or convert copies
32363272 if ! noAuth {
32373273 for _ , item := range authItems {
@@ -3289,13 +3325,13 @@ func cmdSync(args []string) error {
32893325
32903326 // 4. MCP servers
32913327 if ! noMCP && ! cfg .isIsolated ("mcp" ) {
3292- synced , err := syncMCP (dir , sourceClaudeJSON )
3328+ res , err := syncMCP (dir , sourceClaudeJSON , cfg . SyncedMCP , false )
32933329 if err != nil {
32943330 fmt .Fprintf (os .Stderr , " %s: MCP sync error: %v\n " , name , err )
3295- } else if len ( synced ) > 0 {
3296- cfg .SyncedMCP = appendUnique ( cfg . SyncedMCP , synced ... )
3331+ } else if res . changed {
3332+ cfg .SyncedMCP = res . synced
32973333 saveRigConfig (dir , cfg )
3298- fmt .Printf (" %s — synced %d MCP server(s) \n " , name , len ( synced ))
3334+ fmt .Printf (" %s — MCP %s \n " , name , formatMCPReconcile ( res ))
32993335 }
33003336 }
33013337
@@ -4791,17 +4827,28 @@ func cleanupSyncedPlugins(rigDir string, pluginNames []string) {
47914827 }
47924828}
47934829
4794- // syncMCP merges missing MCP servers from source .claude.json into target rig.
4795- // Returns newly synced server names.
4796- func syncMCP (rigDir , sourceClaudeJSON string ) ([]string , error ) {
4830+ // mcpSyncResult describes a reconciliation of a rig's MCP servers against a source.
4831+ type mcpSyncResult struct {
4832+ added []string // servers newly copied from source
4833+ updated []string // owned servers overwritten because source config changed
4834+ removed []string // owned servers dropped because source no longer defines them
4835+ synced []string // authoritative SyncedMCP set after reconciliation
4836+ changed bool // whether anything needs persisting (.claude.json and/or rig.json)
4837+ }
4838+
4839+ // syncMCP reconciles MCP servers in the target rig's .claude.json against the
4840+ // source. syncedMCP is the set of servers claude-rig owns (previously synced):
4841+ // owned entries are added/updated/removed to match the source, while entries the
4842+ // user added locally are left untouched (local precedence). With dryRun, nothing
4843+ // is written — the returned result still describes what would change.
4844+ func syncMCP (rigDir , sourceClaudeJSON string , syncedMCP []string , dryRun bool ) (mcpSyncResult , error ) {
4845+ res := mcpSyncResult {synced : syncedMCP }
4846+
47974847 srcData , err := readJSONFile (sourceClaudeJSON )
47984848 if err != nil {
4799- return nil , nil // no source
4800- }
4801- srcServers , ok := srcData ["mcpServers" ].(map [string ]any )
4802- if ! ok || len (srcServers ) == 0 {
4803- return nil , nil
4849+ return res , nil // no source — nothing to reconcile
48044850 }
4851+ srcServers , _ := srcData ["mcpServers" ].(map [string ]any )
48054852
48064853 tgtClaudeJSON := filepath .Join (rigDir , ".claude.json" )
48074854 tgtData , _ := readJSONFile (tgtClaudeJSON )
@@ -4813,25 +4860,87 @@ func syncMCP(rigDir, sourceClaudeJSON string) ([]string, error) {
48134860 tgtServers = make (map [string ]any )
48144861 }
48154862
4816- var synced []string
4863+ owned := make (map [string ]bool , len (syncedMCP ))
4864+ for _ , n := range syncedMCP {
4865+ owned [n ] = true
4866+ }
4867+
4868+ newOwned := make (map [string ]bool )
4869+
4870+ // Add new servers and update owned ones whose source config changed.
48174871 for name , config := range srcServers {
4818- if _ , exists := tgtServers [name ]; exists {
4819- continue // local takes precedence
4872+ existing , exists := tgtServers [name ]
4873+ switch {
4874+ case ! exists :
4875+ tgtServers [name ] = config
4876+ res .added = append (res .added , name )
4877+ newOwned [name ] = true
4878+ res .changed = true
4879+ case owned [name ]:
4880+ newOwned [name ] = true
4881+ if ! reflect .DeepEqual (existing , config ) {
4882+ tgtServers [name ] = config
4883+ res .updated = append (res .updated , name )
4884+ res .changed = true
4885+ }
4886+ // else: owned and identical — leave as is.
4887+ }
4888+ // else: user-local entry not owned by sync — local precedence, untouched.
4889+ }
4890+
4891+ // Remove owned servers the source no longer defines.
4892+ for _ , name := range syncedMCP {
4893+ if _ , inSrc := srcServers [name ]; inSrc {
4894+ continue
48204895 }
4821- tgtServers [name ] = config
4822- synced = append (synced , name )
4896+ if _ , inTgt := tgtServers [name ]; inTgt {
4897+ delete (tgtServers , name )
4898+ }
4899+ res .removed = append (res .removed , name )
4900+ res .changed = true // ownership shrank — rig.json needs updating
48234901 }
48244902
4825- if len (synced ) == 0 {
4826- return nil , nil
4903+ res .synced = make ([]string , 0 , len (newOwned ))
4904+ for name := range newOwned {
4905+ res .synced = append (res .synced , name )
48274906 }
4907+ sort .Strings (res .synced )
4908+ sort .Strings (res .added )
4909+ sort .Strings (res .updated )
4910+ sort .Strings (res .removed )
48284911
4829- tgtData ["mcpServers" ] = tgtServers
4912+ if dryRun || ! res .changed {
4913+ return res , nil
4914+ }
4915+
4916+ if len (tgtServers ) == 0 {
4917+ delete (tgtData , "mcpServers" )
4918+ } else {
4919+ tgtData ["mcpServers" ] = tgtServers
4920+ }
48304921 if err := writeJSONFile (tgtClaudeJSON , tgtData ); err != nil {
4831- return synced , fmt .Errorf ("writing .claude.json: %w" , err )
4922+ return res , fmt .Errorf ("writing .claude.json: %w" , err )
48324923 }
48334924
4834- return synced , nil
4925+ return res , nil
4926+ }
4927+
4928+ // formatMCPReconcile summarizes a reconcile result as "+N added, ~N updated, -N removed".
4929+ func formatMCPReconcile (res mcpSyncResult ) string {
4930+ var parts []string
4931+ if len (res .added ) > 0 {
4932+ parts = append (parts , fmt .Sprintf ("+%d added" , len (res .added )))
4933+ }
4934+ if len (res .updated ) > 0 {
4935+ parts = append (parts , fmt .Sprintf ("~%d updated" , len (res .updated )))
4936+ }
4937+ if len (res .removed ) > 0 {
4938+ parts = append (parts , fmt .Sprintf ("-%d removed" , len (res .removed )))
4939+ }
4940+ if len (parts ) == 0 {
4941+ return "up to date"
4942+ }
4943+ return strings .Join (parts , ", " )
48354944}
48364945
48374946// cleanupSyncedMCP removes MCP servers that were synced from global.
0 commit comments