diff --git a/internal/config/manager.go b/internal/config/manager.go index 0ade56d..0b69ad2 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -72,6 +72,11 @@ type ConfigMcp struct { // true -> global_mcp_config_file (user-global) Global bool `yaml:"global,omitempty" json:"global,omitempty" jsonschema:"description=When true the MCP is configured in the agent's global config file instead of the project-scoped one"` Merge *bool `yaml:"merge,omitempty" json:"merge,omitempty" jsonschema:"description=Merge server entry into existing file rather than overwriting it (default: true when omitted)"` + // Prune opts this entry's target file in for `gaal sync --prune`. + // Default false — without this gate, --prune was destructive even + // against agent config files holding entries the user added by hand + // outside of gaal. See #142. + Prune bool `yaml:"prune,omitempty" json:"prune,omitempty" jsonschema:"description=Allow gaal sync --prune to remove orphan entries from this MCP target file (default: false)"` Inline *ConfigMcpItem `yaml:"inline,omitempty" json:"inline,omitempty" jsonschema:"description=Inline server definition (mutually exclusive with source)" validate:"omitempty"` } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 43f22a5..13da0e2 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -305,23 +305,36 @@ func mergeIntoTarget(target, name string, entry serverEntry) error { return nil } -// Prune removes mcpServers entries from each managed target file whose names -// are no longer declared in the config for that target. Entries added manually -// outside of gaal are also removed — callers should only use --prune on files -// they intend to manage exclusively with gaal. +// Prune removes mcpServers entries from each managed target file whose +// names are no longer declared in the config for that target. +// +// Per #142, this is gated by an opt-in flag: a target is only pruned +// when at least one ConfigMcp entry pointing at it sets `prune: true`. +// Without that gate `--prune` could silently wipe MCP entries the user +// had added by hand to a shared file (e.g. claude-code's ~/.claude.json, +// codex's ~/.codex/config.toml). func (m *Manager) Prune(ctx context.Context) error { slog.DebugContext(ctx, "pruning orphan mcp entries") - // Build expected name set per target path. + // Build expected name set + per-target prune-opt-in. keepPerTarget := make(map[string]map[string]struct{}) + pruneTarget := make(map[string]bool) for _, mc := range m.resolvedMCPs() { if keepPerTarget[mc.Target] == nil { keepPerTarget[mc.Target] = make(map[string]struct{}) } keepPerTarget[mc.Target][mc.Name] = struct{}{} + if mc.Prune { + pruneTarget[mc.Target] = true + } } for target, keep := range keepPerTarget { + if !pruneTarget[target] { + slog.Debug("mcp prune: target not opted in (set prune: true on at least one entry)", + "target", target) + continue + } codec := codecFor(target) servers, err := codec.ReadServers(target) if err != nil { diff --git a/internal/mcp/manager_test.go b/internal/mcp/manager_test.go index 9fb2f68..c3a5883 100644 --- a/internal/mcp/manager_test.go +++ b/internal/mcp/manager_test.go @@ -718,9 +718,11 @@ func TestMCPPrune_RemovesOrphanEntry(t *testing.T) { initial := `{"mcpServers":{"kept":{"command":"node"},"orphan":{"command":"python"}}}` os.WriteFile(target, []byte(initial), 0o644) - // Config only declares "kept". + // Config only declares "kept" — and opts the target in to pruning + // per #142 (without prune: true on at least one entry, the target + // is not eligible for orphan removal even when --prune is passed). cfg := []config.ConfigMcp{ - {Name: "kept", Target: target, Inline: &config.ConfigMcpItem{Command: "node"}}, + {Name: "kept", Target: target, Prune: true, Inline: &config.ConfigMcpItem{Command: "node"}}, } m := NewManager(cfg, "", "") if err := m.Prune(context.Background()); err != nil {