Skip to content

Commit 07250da

Browse files
committed
fix: sync reconciles MCP servers (update + remove), not just add (#9)
syncMCP now treats SyncedMCP as the owned set and reconciles against source: add new, update owned entries whose source config changed, remove owned entries the source dropped. User-local entries (not owned) keep precedence and are never touched. Adds 'sync --dry-run' to preview the MCP plan. Closes #9
1 parent 5357ee3 commit 07250da

4 files changed

Lines changed: 315 additions & 29 deletions

File tree

cmd/claude-rig/commands.go

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)