diff --git a/internal/ai/service/claude_code_mcp.go b/internal/ai/service/claude_code_mcp.go index 819ce2c7..20a0dbcf 100644 --- a/internal/ai/service/claude_code_mcp.go +++ b/internal/ai/service/claude_code_mcp.go @@ -149,6 +149,114 @@ func (s *Service) AIInstallCodexMCP() (ai.MCPClientInstallResult, error) { }, nil } +// RepairInstalledLocalMCPClientConfigs refreshes stale GoNavi-owned client +// entries after an update or application move. Missing entries and custom +// entries that happen to use the gonavi key are left untouched. +func RepairInstalledLocalMCPClientConfigs(s *Service) error { + if s == nil { + return nil + } + return s.repairInstalledLocalMCPClientConfigs() +} + +func (s *Service) repairInstalledLocalMCPClientConfigs() error { + command, args, err := resolveCurrentLocalMCPCommand(s.serviceText) + if err != nil { + return err + } + + var repairErrors []error + if err := repairClaudeCodeMCPClientConfig(command, args, s.serviceText); err != nil { + repairErrors = append(repairErrors, fmt.Errorf("Claude Code: %w", err)) + } + if err := repairCodexMCPClientConfig(command, args, s.serviceText); err != nil { + repairErrors = append(repairErrors, fmt.Errorf("Codex: %w", err)) + } + return errors.Join(repairErrors...) +} + +func repairClaudeCodeMCPClientConfig(expectedCommand string, expectedArgs []string, text mcpClientInstallTextFunc) error { + configPath, err := claudeCodeConfigPathFunc() + if err != nil { + return err + } + serverConfig, found, err := readClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, text) + if err != nil || !found || sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs) { + return err + } + if !strings.EqualFold(strings.TrimSpace(serverConfig.Type), "stdio") || + !shouldRepairInstalledLocalMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand) { + return nil + } + return upsertClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, claudeCodeMCPServerConfig{ + Type: "stdio", + Command: expectedCommand, + Args: append([]string(nil), expectedArgs...), + Env: map[string]string{}, + }, text) +} + +func repairCodexMCPClientConfig(expectedCommand string, expectedArgs []string, text mcpClientInstallTextFunc) error { + configPath, err := codexConfigPathFunc() + if err != nil { + return err + } + serverConfig, found, err := readCodexMCPServerConfig(configPath, gonaviMCPServerID, text) + if err != nil || !found || sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs) { + return err + } + if !shouldRepairInstalledLocalMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand) { + return nil + } + return upsertCodexMCPServerConfig(configPath, gonaviMCPServerID, codexMCPServerConfig{ + Command: expectedCommand, + Args: append([]string(nil), expectedArgs...), + StartupTimeoutSec: defaultCodexMCPStartupTimeoutSecond, + }, text) +} + +func shouldRepairInstalledLocalMCPCommand(command string, args []string, expectedCommand string) bool { + command = strings.TrimSpace(command) + if command == "" || !isManagedLocalMCPCommand(command, args) { + return false + } + _, err := os.Stat(command) + if errors.Is(err, os.ErrNotExist) { + return true + } + return isSameDirectoryVersionedWindowsGoNaviCommand(command, expectedCommand) +} + +func isSameDirectoryVersionedWindowsGoNaviCommand(command string, expectedCommand string) bool { + if !isVersionedWindowsGoNaviExecutable(command) || !isVersionedWindowsGoNaviExecutable(expectedCommand) { + return false + } + return strings.EqualFold(portablePathDir(command), portablePathDir(expectedCommand)) +} + +func isVersionedWindowsGoNaviExecutable(command string) bool { + baseName := strings.ToLower(portablePathBase(command)) + return strings.HasPrefix(baseName, "gonavi-") && + strings.Contains(baseName, "-windows-") && + strings.HasSuffix(baseName, ".exe") +} + +func isManagedLocalMCPCommand(command string, args []string) bool { + normalizedArgs := normalizeStringSlice(args) + if len(normalizedArgs) == 1 && strings.EqualFold(normalizedArgs[0], "mcp-server") { + baseName := strings.ToLower(portablePathBase(command)) + return baseName == "gonavi" || baseName == "gonavi.exe" || + strings.HasPrefix(baseName, "gonavi-build-") || + isVersionedWindowsGoNaviExecutable(command) || + (strings.HasPrefix(baseName, "gonavi-") && strings.HasSuffix(baseName, ".appimage")) + } + if len(normalizedArgs) != 0 { + return false + } + baseName := strings.ToLower(portablePathBase(command)) + return baseName == "gonavi-mcp-server" || baseName == "gonavi-mcp-server.exe" +} + func resolveCurrentLocalMCPCommand(textFuncs ...mcpClientInstallTextFunc) (string, []string, error) { text := firstMCPClientInstallText(textFuncs) executablePath, err := localMCPExecutablePathFunc() @@ -170,7 +278,7 @@ func resolveLocalMCPCommand(executablePath string, textFuncs ...mcpClientInstall } cleaned := filepath.Clean(executablePath) - baseName := strings.ToLower(strings.TrimSpace(filepath.Base(cleaned))) + baseName := strings.ToLower(portablePathBase(cleaned)) switch baseName { case "gonavi-mcp-server", "gonavi-mcp-server.exe": return cleaned, []string{}, nil @@ -179,6 +287,23 @@ func resolveLocalMCPCommand(executablePath string, textFuncs ...mcpClientInstall } } +func portablePathBase(path string) string { + normalized := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") + return strings.TrimSpace(filepath.Base(normalized)) +} + +func portablePathDir(path string) string { + normalized := strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/") + separator := strings.LastIndex(normalized, "/") + if separator < 0 { + return "." + } + if separator == 0 { + return "/" + } + return normalized[:separator] +} + func detectLocalCLICommand(commandName string) (bool, string) { commandName = strings.TrimSpace(commandName) if commandName == "" { diff --git a/internal/ai/service/claude_code_mcp_test.go b/internal/ai/service/claude_code_mcp_test.go index 6c537a42..3ec1a11c 100644 --- a/internal/ai/service/claude_code_mcp_test.go +++ b/internal/ai/service/claude_code_mcp_test.go @@ -3,6 +3,7 @@ package aiservice import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -39,6 +40,132 @@ func TestResolveLocalMCPCommandKeepsDedicatedServerBinary(t *testing.T) { } } +func TestRepairInstalledLocalMCPClientConfigsUpdatesMissingManagedCommands(t *testing.T) { + originalClaudeConfigPathFunc := claudeCodeConfigPathFunc + originalCodexConfigPathFunc := codexConfigPathFunc + originalExecutablePathFunc := localMCPExecutablePathFunc + t.Cleanup(func() { + claudeCodeConfigPathFunc = originalClaudeConfigPathFunc + codexConfigPathFunc = originalCodexConfigPathFunc + localMCPExecutablePathFunc = originalExecutablePathFunc + }) + + tempDir := t.TempDir() + claudeConfigPath := filepath.Join(tempDir, ".claude.json") + codexConfigPath := filepath.Join(tempDir, ".codex", "config.toml") + currentExecutable := filepath.Join(tempDir, "current", "GoNavi.exe") + if err := os.MkdirAll(filepath.Dir(currentExecutable), 0o755); err != nil { + t.Fatalf("MkdirAll current executable dir returned error: %v", err) + } + if err := os.WriteFile(currentExecutable, []byte("current"), 0o755); err != nil { + t.Fatalf("WriteFile current executable returned error: %v", err) + } + missingOldExecutable := filepath.Join(tempDir, "old", "GoNavi-0.8.0-Windows-Amd64.exe") + + claudeConfig := map[string]any{ + "theme": "dark", + "mcpServers": map[string]any{ + "memory": map[string]any{"type": "stdio", "command": "memory-server"}, + gonaviMCPServerID: map[string]any{ + "type": "stdio", + "command": missingOldExecutable, + "args": []string{"mcp-server"}, + }, + }, + } + claudeData, err := json.MarshalIndent(claudeConfig, "", " ") + if err != nil { + t.Fatalf("MarshalIndent Claude config returned error: %v", err) + } + if err := os.WriteFile(claudeConfigPath, append(claudeData, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile Claude config returned error: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(codexConfigPath), 0o755); err != nil { + t.Fatalf("MkdirAll Codex config dir returned error: %v", err) + } + codexConfig := strings.Join([]string{ + `model = "gpt-5.4"`, + ``, + `[mcp_servers.memory]`, + `command = "memory-server"`, + ``, + `[mcp_servers.gonavi]`, + fmt.Sprintf("command = %s", tomlString(missingOldExecutable)), + `args = ['mcp-server']`, + `startup_timeout_sec = 60`, + ``, + }, "\n") + if err := os.WriteFile(codexConfigPath, []byte(codexConfig), 0o644); err != nil { + t.Fatalf("WriteFile Codex config returned error: %v", err) + } + + claudeCodeConfigPathFunc = func() (string, error) { return claudeConfigPath, nil } + codexConfigPathFunc = func() (string, error) { return codexConfigPath, nil } + localMCPExecutablePathFunc = func() (string, error) { return currentExecutable, nil } + + service := NewService() + if err := service.repairInstalledLocalMCPClientConfigs(); err != nil { + t.Fatalf("repairInstalledLocalMCPClientConfigs returned error: %v", err) + } + + statuses := service.AIGetMCPClientInstallStatuses() + if len(statuses) < 2 || !statuses[0].MatchesCurrent || !statuses[1].MatchesCurrent { + t.Fatalf("expected Claude Code and Codex configs to match current executable, got %#v", statuses) + } + + updatedClaudeData, err := os.ReadFile(claudeConfigPath) + if err != nil { + t.Fatalf("ReadFile updated Claude config returned error: %v", err) + } + if !strings.Contains(string(updatedClaudeData), `"memory"`) || !strings.Contains(string(updatedClaudeData), `"theme": "dark"`) { + t.Fatalf("expected unrelated Claude config to remain, got %s", string(updatedClaudeData)) + } + updatedCodexData, err := os.ReadFile(codexConfigPath) + if err != nil { + t.Fatalf("ReadFile updated Codex config returned error: %v", err) + } + if !strings.Contains(string(updatedCodexData), `[mcp_servers.memory]`) || !strings.Contains(string(updatedCodexData), `model = "gpt-5.4"`) { + t.Fatalf("expected unrelated Codex config to remain, got %s", string(updatedCodexData)) + } +} + +func TestRepairInstalledLocalMCPClientConfigsLeavesExistingOrCustomCommandsUntouched(t *testing.T) { + existingManagedCommand := filepath.Join(t.TempDir(), "GoNavi.exe") + if err := os.WriteFile(existingManagedCommand, []byte("old but available"), 0o755); err != nil { + t.Fatalf("WriteFile existing managed command returned error: %v", err) + } + + if shouldRepairInstalledLocalMCPCommand(existingManagedCommand, []string{"mcp-server"}, filepath.Join(t.TempDir(), "GoNavi.exe")) { + t.Fatal("expected an existing managed command to remain untouched") + } + if shouldRepairInstalledLocalMCPCommand("missing-custom-proxy", []string{"--serve"}, "GoNavi.exe") { + t.Fatal("expected a custom command to remain untouched") + } + if shouldRepairInstalledLocalMCPCommand("missing-custom-proxy", []string{"mcp-server"}, "GoNavi.exe") { + t.Fatal("expected a missing non-GoNavi command to remain untouched") + } + if !shouldRepairInstalledLocalMCPCommand(filepath.Join(t.TempDir(), "missing", "GoNavi.exe"), []string{"mcp-server"}, "GoNavi.exe") { + t.Fatal("expected a missing managed GoNavi command to be repaired") + } +} + +func TestShouldRepairInstalledLocalMCPCommandUpdatesExistingVersionedWindowsTarget(t *testing.T) { + dir := t.TempDir() + oldCommand := filepath.Join(dir, "GoNavi-0.8.4-Windows-Amd64.exe") + newCommand := filepath.Join(dir, "GoNavi-0.8.5-Windows-Amd64.exe") + if err := os.WriteFile(oldCommand, []byte("still locked by an MCP process"), 0o755); err != nil { + t.Fatalf("WriteFile old command returned error: %v", err) + } + + if !shouldRepairInstalledLocalMCPCommand(oldCommand, []string{"mcp-server"}, newCommand) { + t.Fatal("expected a same-directory versioned Windows update to refresh the MCP command") + } + if shouldRepairInstalledLocalMCPCommand(oldCommand, []string{"mcp-server"}, filepath.Join(t.TempDir(), filepath.Base(newCommand))) { + t.Fatal("expected a versioned command in another directory to remain untouched") + } +} + func TestReadClaudeCodeMCPServerConfigReadsExistingInstall(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, ".claude.json") diff --git a/main.go b/main.go index 2b62d74c..86bfb09d 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,9 @@ func main() { runtimeCtx = ctx app.InitializeLifecycle(application, ctx) aiservice.InitializeLifecycle(aiService, ctx) + if err := aiservice.RepairInstalledLocalMCPClientConfigs(aiService); err != nil { + logger.Warnf("自动修复本地 MCP 客户端配置失败:%v", err) + } }, OnShutdown: func(ctx context.Context) { aiService.Shutdown()