🐛 fix(mcp): 修复在线更新后外部客户端连接失效

- 启动时自动迁移 Claude Code 与 Codex 中失效的 GoNavi MCP 路径
- 兼容 Windows 版本化 EXE 改名及旧进程占用导致的残留路径
- 限制迁移范围,保留仍有效的稳定路径与自定义命令
- 补充路径迁移和误覆盖边界回归测试
This commit is contained in:
Syngnat
2026-07-10 09:44:26 +08:00
parent 52592ffd6c
commit 155ba0f1f7
3 changed files with 256 additions and 1 deletions

View File

@@ -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 == "" {

View File

@@ -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")

View File

@@ -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()