Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/agents/claude/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ func (a *Adapter) MCPConfigPath(homeDir string, serverName string) string {
return filepath.Join(homeDir, ".claude", "mcp", serverName+".json")
}

// MCPRegistryPath satisfies agents.MCPRegistryPathProvider. Components use it
// to write MCP servers where Claude Code actually reads them; MCPConfigPath is
// kept for cleaning up registrations earlier versions left behind.
func (a *Adapter) MCPRegistryPath(homeDir string) string {
return MCPRegistryPath(homeDir)
}

// --- Optional capabilities ---

func (a *Adapter) SupportsOutputStyles() bool {
Expand Down
8 changes: 8 additions & 0 deletions internal/agents/claude/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@ import "path/filepath"
func ConfigPath(homeDir string) string {
return filepath.Join(homeDir, ".claude")
}

// MCPRegistryPath returns ~/.claude.json, the only user-scope location Claude
// Code loads MCP servers from. An mcpServers key written to
// ~/.claude/settings.json is not a recognised setting and is ignored, and
// ~/.claude/mcp/*.json is never read at all.
func MCPRegistryPath(homeDir string) string {
return filepath.Join(homeDir, ".claude.json")
}
20 changes: 20 additions & 0 deletions internal/agents/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,23 @@ type Adapter interface {
type EffectiveCodeGraphWiringDetector interface {
EffectiveCodeGraphWiring(homeDir string) (path string, configured bool)
}

// MCPRegistryPathProvider is an optional adapter capability for agents that
// load MCP servers from a dedicated registry file instead of from the settings
// file their other configuration lives in. Claude Code is the case in point: it
// reads user-scope MCP servers from ~/.claude.json only.
type MCPRegistryPathProvider interface {
MCPRegistryPath(homeDir string) string
}

// MCPRegistryPath returns the adapter's MCP registry file, or an empty string
// when the agent has none and its MCPStrategy alone decides where MCP servers
// are written.
func MCPRegistryPath(adapter Adapter, homeDir string) string {
provider, ok := adapter.(MCPRegistryPathProvider)
if !ok {
return ""
}

return provider.MCPRegistryPath(homeDir)
}
6 changes: 2 additions & 4 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -1826,10 +1826,8 @@ func componentPathsWithWorkspaceScoped(homeDir, workspaceDir string, scope Insta
case model.ComponentContext7:
switch adapter.MCPStrategy() {
case model.StrategySeparateMCPFiles:
if adapter.Agent() == model.AgentClaudeCode {
if p := adapter.SettingsPath(homeDir); p != "" {
paths = append(paths, p)
}
if p := agents.MCPRegistryPath(adapter, homeDir); p != "" {
paths = append(paths, p)
break
}
paths = append(paths, adapter.MCPConfigPath(homeDir, "context7"))
Expand Down
15 changes: 10 additions & 5 deletions internal/cli/run_component_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,19 +355,24 @@ func TestComponentPathsContext7KimiIncludesMCPConfig(t *testing.T) {
}
}

func TestComponentPathsContext7ClaudeUsesSettingsFile(t *testing.T) {
func TestComponentPathsContext7ClaudeUsesMCPRegistry(t *testing.T) {
home := t.TempDir()
adapters := resolveAdapters([]model.AgentID{model.AgentClaudeCode})

paths := componentPaths(home, model.Selection{}, adapters, model.ComponentContext7)

want := filepath.Join(home, ".claude", "settings.json")
// Verification and backup must track the file the injector actually writes.
want := filepath.Join(home, ".claude.json")
if !containsPath(paths, want) {
t.Fatalf("componentPaths(context7,claude) missing %q\npaths=%v", want, paths)
}
legacy := filepath.Join(home, ".claude", "mcp", "context7.json")
if containsPath(paths, legacy) {
t.Fatalf("componentPaths(context7,claude) should not verify legacy path %q\npaths=%v", legacy, paths)
for _, unwanted := range []string{
filepath.Join(home, ".claude", "settings.json"),
filepath.Join(home, ".claude", "mcp", "context7.json"),
} {
if containsPath(paths, unwanted) {
t.Fatalf("componentPaths(context7,claude) should not verify %q\npaths=%v", unwanted, paths)
}
}
}

Expand Down
14 changes: 14 additions & 0 deletions internal/components/filemerge/json_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ func MergeJSONObjects(baseJSON []byte, overlayJSON []byte) ([]byte, error) {
return append(encoded, '\n'), nil
}

// MergeJSONObjectsStrict merges like MergeJSONObjects but refuses to proceed
// when the base document cannot be decoded, instead of falling back to an empty
// base. Use it for files that carry irreplaceable user state: Claude Code's
// ~/.claude.json holds the authenticated session and per-project trust
// settings, so silently rewriting it from an empty object would sign the user
// out rather than lose a reproducible config.
func MergeJSONObjectsStrict(baseJSON []byte, overlayJSON []byte) ([]byte, error) {
if _, err := unmarshalJSONObject(baseJSON); err != nil {
return nil, fmt.Errorf("refusing to rewrite malformed json base: %w", err)
}

return MergeJSONObjects(baseJSON, overlayJSON)
}

func unmarshalJSONObject(raw []byte) (map[string]any, error) {
object := map[string]any{}
if len(bytes.TrimSpace(raw)) == 0 {
Expand Down
50 changes: 50 additions & 0 deletions internal/components/filemerge/json_merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,56 @@ func TestMergeJSONObjectsMalformedBaseReturnsOverlayOnly(t *testing.T) {
}
}

func TestMergeJSONObjectsStrictRefusesMalformedBase(t *testing.T) {
// The lenient path treats a broken base as {}, which is right for a config
// the installer can regenerate. A file such as Claude Code's ~/.claude.json
// carries the authenticated session, so the strict path must fail instead.
bases := [][]byte{
[]byte(`allow: all`),
[]byte(`{"oauthAccount": {"accountUuid": "abc"`),
[]byte(`a`),
}

for _, base := range bases {
t.Run(string(base), func(t *testing.T) {
merged, err := MergeJSONObjectsStrict(base, []byte(`{"mcpServers":{"context7":{"command":"npx"}}}`))
if err == nil {
t.Fatalf("MergeJSONObjectsStrict() error = nil, merged = %s; want a refusal", merged)
}
if merged != nil {
t.Fatalf("MergeJSONObjectsStrict() merged = %s; want nil on refusal", merged)
}
})
}
}

func TestMergeJSONObjectsStrictMergesParsableBase(t *testing.T) {
base := []byte(`{"oauthAccount":{"accountUuid":"abc"},"mcpServers":{"codegraph":{"command":"codegraph"}}}`)

merged, err := MergeJSONObjectsStrict(base, []byte(`{"mcpServers":{"context7":{"command":"npx"}}}`))
if err != nil {
t.Fatalf("MergeJSONObjectsStrict() error = %v", err)
}

var got map[string]any
if err := json.Unmarshal(merged, &got); err != nil {
t.Fatalf("merged result is not valid JSON: %v", err)
}
if _, ok := got["oauthAccount"]; !ok {
t.Fatalf("strict merge dropped oauthAccount; got keys: %v", got)
}

servers, ok := got["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("merged result missing mcpServers; got: %v", got)
}
for _, name := range []string{"codegraph", "context7"} {
if _, ok := servers[name]; !ok {
t.Fatalf("mcpServers missing %q; got: %v", name, servers)
}
}
}

// ─── __replace__ sentinel tests ───────────────────────────────────────────────

func TestMergeJSONObjectsReplaceSentinelErasesBaseKeys(t *testing.T) {
Expand Down
27 changes: 25 additions & 2 deletions internal/components/mcp/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func Inject(homeDir string, adapter agents.Adapter) (InjectionResult, error) {

switch adapter.MCPStrategy() {
case model.StrategySeparateMCPFiles:
if adapter.Agent() == model.AgentClaudeCode {
return injectMergeIntoSettings(homeDir, adapter)
if registryPath := agents.MCPRegistryPath(adapter, homeDir); registryPath != "" {
return injectMCPRegistry(registryPath)
}
return injectSeparateFile(homeDir, adapter)
case model.StrategyMergeIntoSettings:
Expand Down Expand Up @@ -96,6 +96,29 @@ func injectYAMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, er
return InjectionResult{Changed: writeResult.Changed, Files: []string{configPath}}, nil
}

// injectMCPRegistry merges the mcpServers-wrapped Context7 entry into the
// agent's MCP registry file (Claude Code's ~/.claude.json). That file also
// holds the user's session and per-project settings, so the merge is strict: a
// base that cannot be parsed aborts the injection instead of replacing it.
func injectMCPRegistry(registryPath string) (InjectionResult, error) {
baseJSON, err := osReadFile(registryPath)
if err != nil {
return InjectionResult{}, err
}

merged, err := filemerge.MergeJSONObjectsStrict(baseJSON, DefaultContext7OverlayJSON())
if err != nil {
return InjectionResult{}, fmt.Errorf("merge MCP registry %q: %w", registryPath, err)
}

writeResult, err := filemerge.WriteFileAtomic(registryPath, merged, 0o644)
if err != nil {
return InjectionResult{}, err
}

return InjectionResult{Changed: writeResult.Changed, Files: []string{registryPath}}, nil
}

// injectSeparateFile writes a standalone JSON file per MCP server.
func injectSeparateFile(homeDir string, adapter agents.Adapter) (InjectionResult, error) {
path := adapter.MCPConfigPath(homeDir, "context7")
Expand Down
76 changes: 66 additions & 10 deletions internal/components/mcp/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func assertOpenCodeRemoteContext7Schema(t *testing.T, path string) {

// readMCPServersContext7Entry reads the mcpServers.context7 object used by
// agents that store Context7 under an mcpServers-based config file.
func readMCPServersContext7Entry(t *testing.T, path string) map[string]any {
func readJSONObject(t *testing.T, path string) map[string]any {
t.Helper()

content, err := os.ReadFile(path)
Expand All @@ -124,6 +124,14 @@ func readMCPServersContext7Entry(t *testing.T, path string) map[string]any {
t.Fatalf("Unmarshal(%q) error = %v", path, err)
}

return parsed
}

func readMCPServersContext7Entry(t *testing.T, path string) map[string]any {
t.Helper()

parsed := readJSONObject(t, path)

mcpServers, ok := parsed["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("%q missing object key mcpServers; got %#v", path, parsed["mcpServers"])
Expand Down Expand Up @@ -476,14 +484,11 @@ func TestInjectOpenCodePreservesOtherMCPEntriesWhenReplacingContext7(t *testing.
}
}

func TestInjectClaudeMergesContext7IntoSettingsAndIsIdempotent(t *testing.T) {
func TestInjectClaudeMergesContext7IntoRegistryAndIsIdempotent(t *testing.T) {
home := t.TempDir()
settingsPath := filepath.Join(home, ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatalf("MkdirAll(settings dir) error = %v", err)
}
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark"}`), 0o644); err != nil {
t.Fatalf("WriteFile(settings) error = %v", err)
registryPath := claude.MCPRegistryPath(home)
if err := os.WriteFile(registryPath, []byte(`{"oauthAccount":{"accountUuid":"abc"},"projects":{"/tmp/x":{"allowedTools":[]}}}`), 0o644); err != nil {
t.Fatalf("WriteFile(registry) error = %v", err)
}

first, err := Inject(home, claudeAdapter())
Expand All @@ -502,13 +507,64 @@ func TestInjectClaudeMergesContext7IntoSettingsAndIsIdempotent(t *testing.T) {
t.Fatalf("Inject() second changed = true")
}

context7 := readMCPServersContext7Entry(t, settingsPath)
context7 := readMCPServersContext7Entry(t, registryPath)
if got := context7["command"]; got != "npx" {
t.Fatalf("mcpServers.context7.command = %#v; want npx", got)
}

// The session and per-project state living in the same file must survive.
root := readJSONObject(t, registryPath)
if _, ok := root["oauthAccount"]; !ok {
t.Fatalf("oauthAccount was dropped from %q", registryPath)
}
if _, ok := root["projects"]; !ok {
t.Fatalf("projects was dropped from %q", registryPath)
}

// Neither location Claude Code ignores may be written.
if _, err := os.Stat(filepath.Join(home, ".claude", "mcp", "context7.json")); !os.IsNotExist(err) {
t.Fatalf("Claude Context7 must not be written to ~/.claude/mcp/context7.json; stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".claude", "settings.json")); !os.IsNotExist(err) {
t.Fatalf("Claude Context7 must not be written to ~/.claude/settings.json; stat err = %v", err)
}
}

func TestInjectClaudeCreatesRegistryWhenMissing(t *testing.T) {
home := t.TempDir()

if _, err := Inject(home, claudeAdapter()); err != nil {
t.Fatalf("Inject() error = %v", err)
}

context7 := readMCPServersContext7Entry(t, claude.MCPRegistryPath(home))
if got := context7["command"]; got != "npx" {
t.Fatalf("mcpServers.context7.command = %#v; want npx", got)
}
}

func TestInjectClaudeRefusesToRewriteMalformedRegistry(t *testing.T) {
// ~/.claude.json carries the authenticated session. Unlike a reproducible
// mcp.json, a base we cannot parse must abort the injection rather than be
// replaced with an overlay-only document.
home := t.TempDir()
registryPath := claude.MCPRegistryPath(home)
malformed := []byte("not json at all")
if err := os.WriteFile(registryPath, malformed, 0o644); err != nil {
t.Fatalf("WriteFile(malformed registry) error = %v", err)
}

if _, err := Inject(home, claudeAdapter()); err == nil {
t.Fatalf("Inject() error = nil; want a refusal to rewrite the malformed registry")
}

content, err := os.ReadFile(registryPath)
if err != nil {
t.Fatalf("ReadFile(registry) error = %v", err)
}
if string(content) != string(malformed) {
t.Fatalf("registry was rewritten; got %q, want %q", content, malformed)
}
}

func TestInjectClaudeLeavesLegacyContext7FileForExplicitUninstallCleanup(t *testing.T) {
Expand All @@ -527,7 +583,7 @@ func TestInjectClaudeLeavesLegacyContext7FileForExplicitUninstallCleanup(t *test
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("legacy context7 file should be left for explicit uninstall cleanup: %v", err)
}
readMCPServersContext7Entry(t, filepath.Join(home, ".claude", "settings.json"))
readMCPServersContext7Entry(t, claude.MCPRegistryPath(home))
}

func TestInjectCursorWithMalformedMCPJsonRecovery(t *testing.T) {
Expand Down
47 changes: 43 additions & 4 deletions internal/components/uninstall/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,10 @@ func (s *Service) componentOperations(adapter agents.Adapter, componentID model.
func context7Targets(adapter agents.Adapter, homeDir string) []string {
switch adapter.MCPStrategy() {
case model.StrategySeparateMCPFiles:
if adapter.Agent() == model.AgentClaudeCode {
return []string{adapter.SettingsPath(homeDir), adapter.MCPConfigPath(homeDir, "context7")}
if registryPath := agents.MCPRegistryPath(adapter, homeDir); registryPath != "" {
// SettingsPath and MCPConfigPath stay listed so registrations written
// by earlier versions, which never reached the registry, are cleaned up.
return []string{registryPath, adapter.SettingsPath(homeDir), adapter.MCPConfigPath(homeDir, "context7")}
}
return []string{adapter.MCPConfigPath(homeDir, "context7")}
case model.StrategyMergeIntoSettings, model.StrategyMCPConfigFile:
Expand All @@ -735,9 +737,14 @@ func context7Targets(adapter agents.Adapter, homeDir string) []string {
func context7Operations(adapter agents.Adapter, homeDir string) []operation {
switch adapter.MCPStrategy() {
case model.StrategySeparateMCPFiles:
if adapter.Agent() == model.AgentClaudeCode {
if registryPath := agents.MCPRegistryPath(adapter, homeDir); registryPath != "" {
legacyPath := adapter.MCPConfigPath(homeDir, "context7")
return []operation{rewriteJSONFile(adapter.SettingsPath(homeDir), jsonPath{"mcpServers", "context7"}), removeManagedContext7File(legacyPath), removeDirIfEmpty(filepath.Dir(legacyPath))}
return []operation{
rewriteMCPRegistryFile(registryPath, jsonPath{"mcpServers", "context7"}),
rewriteJSONFile(adapter.SettingsPath(homeDir), jsonPath{"mcpServers", "context7"}),
removeManagedContext7File(legacyPath),
removeDirIfEmpty(filepath.Dir(legacyPath)),
}
}
path := adapter.MCPConfigPath(homeDir, "context7")
return []operation{removeFile(path), removeDirIfEmpty(filepath.Dir(path))}
Expand Down Expand Up @@ -843,6 +850,38 @@ func rewriteMarkdownFile(path string, mutate func(content string) (string, bool)
}
}

// rewriteMCPRegistryFile removes jsonPaths from an agent's MCP registry file
// without ever deleting it. rewriteJSONFile drops a file that cleans out to an
// empty object, which is right for a config gentle-ai created but wrong for
// Claude Code's ~/.claude.json: that file also carries the authenticated
// session, so an uninstall must leave "{}" behind rather than sign the user out.
func rewriteMCPRegistryFile(path string, jsonPaths ...jsonPath) operation {
return operation{
typeID: opRewriteFile,
path: path,
apply: func(path string) (bool, bool, error) {
raw, err := readManagedFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, false, nil
}
return false, false, fmt.Errorf("read json file %q: %w", path, err)
}
updated, changed, err := removeJSONPaths(raw, jsonPaths...)
if err != nil {
return false, false, fmt.Errorf("clean json file %q: %w", path, err)
}
if !changed {
return false, false, nil
}
if _, err := filemerge.WriteFileAtomic(path, updated, 0o644); err != nil {
return false, false, err
}
return true, false, nil
},
}
}

func rewriteJSONFile(path string, jsonPaths ...jsonPath) operation {
return operation{
typeID: opRewriteFile,
Expand Down
Loading