mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
🐛 fix(mcp): 修复在线更新后外部客户端连接失效
- 启动时自动迁移 Claude Code 与 Codex 中失效的 GoNavi MCP 路径 - 兼容 Windows 版本化 EXE 改名及旧进程占用导致的残留路径 - 限制迁移范围,保留仍有效的稳定路径与自定义命令 - 补充路径迁移和误覆盖边界回归测试
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
3
main.go
3
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()
|
||||
|
||||
Reference in New Issue
Block a user