Files
MyGoNavi/internal/ai/service/claude_code_mcp.go
2026-06-22 15:12:42 +08:00

765 lines
26 KiB
Go

package aiservice
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"GoNavi-Wails/internal/ai"
)
const (
gonaviMCPServerID = "gonavi"
defaultCodexMCPStartupTimeoutSecond = 60
claudeCodeClientCommandName = "claude"
codexClientCommandName = "codex"
)
type mcpClientInstallTextFunc func(string, map[string]any) string
var errMCPClientUserHomeDirUnavailable = errors.New("user home directory is unavailable")
var claudeCodeConfigPathFunc = func() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
homeDir = strings.TrimSpace(homeDir)
if homeDir == "" {
return "", errMCPClientUserHomeDirUnavailable
}
return filepath.Join(homeDir, ".claude.json"), nil
}
var codexConfigPathFunc = func() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
homeDir = strings.TrimSpace(homeDir)
if homeDir == "" {
return "", errMCPClientUserHomeDirUnavailable
}
return filepath.Join(homeDir, ".codex", "config.toml"), nil
}
var localMCPExecutablePathFunc = os.Executable
var localCLICommandPathFunc = exec.LookPath
type claudeCodeMCPServerConfig struct {
Type string `json:"type"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
type codexMCPServerConfig struct {
Command string
Args []string
StartupTimeoutSec int
}
// AIGetMCPClientInstallStatuses 返回 GoNavi MCP 在常见外部客户端中的安装状态。
func (s *Service) AIGetMCPClientInstallStatuses() []ai.MCPClientInstallStatus {
command, args, resolveErr := resolveCurrentLocalMCPCommand(s.serviceText)
return []ai.MCPClientInstallStatus{
inspectClaudeCodeMCPInstallStatus(command, args, resolveErr, s.serviceText),
inspectCodexMCPInstallStatus(command, args, resolveErr, s.serviceText),
buildRemoteMCPClientInstallStatus("openclaw", "OpenClaw", s.serviceText),
buildRemoteMCPClientInstallStatus("hermans", "Hermans", s.serviceText),
}
}
// AIInstallClaudeCodeMCP 把 GoNavi 的 MCP server 写入 Claude Code 用户级 MCP 配置。
func (s *Service) AIInstallClaudeCodeMCP() (ai.MCPClientInstallResult, error) {
configPath, err := claudeCodeConfigPathFunc()
if err != nil {
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.claude_code.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(s.serviceText, err)}))
}
executablePath, err := localMCPExecutablePathFunc()
if err != nil {
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
}
command, args, err := resolveLocalMCPCommand(executablePath, s.serviceText)
if err != nil {
return ai.MCPClientInstallResult{}, err
}
serverConfig := claudeCodeMCPServerConfig{
Type: "stdio",
Command: command,
Args: append([]string(nil), args...),
Env: map[string]string{},
}
if err := upsertClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, serverConfig, s.serviceText); err != nil {
return ai.MCPClientInstallResult{}, err
}
return ai.MCPClientInstallResult{
Success: true,
Client: "claude-code",
Message: s.serviceText("ai.service.mcp_client.claude_code.install_success", nil),
ConfigPath: configPath,
Command: command,
Args: append([]string(nil), args...),
}, nil
}
// AIInstallCodexMCP 把 GoNavi 的 MCP server 写入 Codex 用户级 MCP 配置。
func (s *Service) AIInstallCodexMCP() (ai.MCPClientInstallResult, error) {
configPath, err := codexConfigPathFunc()
if err != nil {
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.codex.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(s.serviceText, err)}))
}
executablePath, err := localMCPExecutablePathFunc()
if err != nil {
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
}
command, args, err := resolveLocalMCPCommand(executablePath, s.serviceText)
if err != nil {
return ai.MCPClientInstallResult{}, err
}
serverConfig := codexMCPServerConfig{
Command: command,
Args: append([]string(nil), args...),
StartupTimeoutSec: defaultCodexMCPStartupTimeoutSecond,
}
if err := upsertCodexMCPServerConfig(configPath, gonaviMCPServerID, serverConfig, s.serviceText); err != nil {
return ai.MCPClientInstallResult{}, err
}
return ai.MCPClientInstallResult{
Success: true,
Client: "codex",
Message: s.serviceText("ai.service.mcp_client.codex.install_success", nil),
ConfigPath: configPath,
Command: command,
Args: append([]string(nil), args...),
}, nil
}
func resolveCurrentLocalMCPCommand(textFuncs ...mcpClientInstallTextFunc) (string, []string, error) {
text := firstMCPClientInstallText(textFuncs)
executablePath, err := localMCPExecutablePathFunc()
if err != nil {
return "", nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
}
command, args, err := resolveLocalMCPCommand(executablePath, text)
if err != nil {
return "", nil, err
}
return command, args, nil
}
func resolveLocalMCPCommand(executablePath string, textFuncs ...mcpClientInstallTextFunc) (string, []string, error) {
text := firstMCPClientInstallText(textFuncs)
executablePath = strings.TrimSpace(executablePath)
if executablePath == "" {
return "", nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.executable_path_empty", nil))
}
cleaned := filepath.Clean(executablePath)
baseName := strings.ToLower(strings.TrimSpace(filepath.Base(cleaned)))
switch baseName {
case "gonavi-mcp-server", "gonavi-mcp-server.exe":
return cleaned, []string{}, nil
default:
return cleaned, []string{"mcp-server"}, nil
}
}
func detectLocalCLICommand(commandName string) (bool, string) {
commandName = strings.TrimSpace(commandName)
if commandName == "" {
return false, ""
}
resolvedPath, err := localCLICommandPathFunc(commandName)
if err != nil {
return false, ""
}
resolvedPath = strings.TrimSpace(resolvedPath)
if resolvedPath == "" {
return false, ""
}
return true, filepath.Clean(resolvedPath)
}
func mcpClientInstallText(text mcpClientInstallTextFunc, key string, params map[string]any) string {
if text == nil {
return serviceTextFromLocalizer(nil, key, params)
}
return text(key, params)
}
func firstMCPClientInstallText(textFuncs []mcpClientInstallTextFunc) mcpClientInstallTextFunc {
if len(textFuncs) == 0 {
return nil
}
return textFuncs[0]
}
func localizeMCPClientPathDetail(text mcpClientInstallTextFunc, err error) string {
if err == nil {
return ""
}
detail := strings.TrimSpace(err.Error())
if errors.Is(err, errMCPClientUserHomeDirUnavailable) || detail == errMCPClientUserHomeDirUnavailable.Error() {
return mcpClientInstallText(text, "ai.service.mcp_client.user_home_dir_unavailable", nil)
}
return detail
}
func inspectClaudeCodeMCPInstallStatus(expectedCommand string, expectedArgs []string, expectedErr error, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
text := firstMCPClientInstallText(textFuncs)
configPath, pathErr := claudeCodeConfigPathFunc()
clientDetected, clientPath := detectLocalCLICommand(claudeCodeClientCommandName)
status := ai.MCPClientInstallStatus{
Client: "claude-code",
DisplayName: "Claude Code",
InstallMode: "auto",
ClientDetected: clientDetected,
ClientCommand: claudeCodeClientCommandName,
ClientPath: clientPath,
ConfigPath: strings.TrimSpace(configPath),
Message: mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.missing", nil),
}
if pathErr != nil {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(text, pathErr)})
return status
}
serverConfig, found, err := readClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, text)
if err != nil {
status.Installed = found
status.Message = err.Error()
if found {
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
}
return status
}
if !found {
return status
}
status.Installed = true
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
if expectedErr != nil {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.path_check_failed", map[string]any{"detail": expectedErr.Error()})
return status
}
status.MatchesCurrent = strings.EqualFold(strings.TrimSpace(serverConfig.Type), "stdio") &&
sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs)
if status.MatchesCurrent {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.connected", nil)
return status
}
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.path_mismatch", nil)
return status
}
func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string, expectedErr error, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
text := firstMCPClientInstallText(textFuncs)
configPath, pathErr := codexConfigPathFunc()
clientDetected, clientPath := detectLocalCLICommand(codexClientCommandName)
status := ai.MCPClientInstallStatus{
Client: "codex",
DisplayName: "Codex",
InstallMode: "auto",
ClientDetected: clientDetected,
ClientCommand: codexClientCommandName,
ClientPath: clientPath,
ConfigPath: strings.TrimSpace(configPath),
Message: mcpClientInstallText(text, "ai.service.mcp_client.codex.status.missing", nil),
}
if pathErr != nil {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(text, pathErr)})
return status
}
serverConfig, found, err := readCodexMCPServerConfig(configPath, gonaviMCPServerID, text)
if err != nil {
status.Installed = found
status.Message = err.Error()
if found {
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
}
return status
}
if !found {
return status
}
status.Installed = true
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
if expectedErr != nil {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.path_check_failed", map[string]any{"detail": expectedErr.Error()})
return status
}
status.MatchesCurrent = sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs) &&
(serverConfig.StartupTimeoutSec == 0 || serverConfig.StartupTimeoutSec == defaultCodexMCPStartupTimeoutSecond)
if status.MatchesCurrent {
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.connected", nil)
return status
}
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.path_mismatch", nil)
return status
}
func buildRemoteMCPClientInstallStatus(client string, displayName string, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
text := firstMCPClientInstallText(textFuncs)
return ai.MCPClientInstallStatus{
Client: client,
DisplayName: displayName,
InstallMode: "remote",
ClientDetected: false,
Message: mcpClientInstallText(text, "ai.service.mcp_client.remote.status.message", map[string]any{"label": displayName}),
}
}
func readClaudeCodeMCPServerConfig(configPath string, serverID string, textFuncs ...mcpClientInstallTextFunc) (claudeCodeMCPServerConfig, bool, error) {
text := firstMCPClientInstallText(textFuncs)
root, err := readClaudeCodeConfig(configPath, text)
if err != nil {
return claudeCodeMCPServerConfig{}, false, err
}
rawServers, exists := root["mcpServers"]
if !exists || rawServers == nil {
return claudeCodeMCPServerConfig{}, false, nil
}
mcpServers, ok := rawServers.(map[string]any)
if !ok {
return claudeCodeMCPServerConfig{}, false, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": "mcpServers", "expected": "an object"}))
}
rawServer, exists := mcpServers[strings.TrimSpace(serverID)]
if !exists || rawServer == nil {
return claudeCodeMCPServerConfig{}, false, nil
}
serverMap, ok := rawServer.(map[string]any)
if !ok {
return claudeCodeMCPServerConfig{}, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcpServers.%s", strings.TrimSpace(serverID)), "expected": "an object"}))
}
args, err := decodeJSONLikeStringSlice(serverMap["args"])
if err != nil {
return claudeCodeMCPServerConfig{}, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcpServers.%s.args", strings.TrimSpace(serverID)), "expected": "a string array"}))
}
return claudeCodeMCPServerConfig{
Type: strings.TrimSpace(anyString(serverMap["type"])),
Command: strings.TrimSpace(anyString(serverMap["command"])),
Args: args,
}, true, nil
}
func upsertClaudeCodeMCPServerConfig(configPath string, serverID string, serverConfig claudeCodeMCPServerConfig, textFuncs ...mcpClientInstallTextFunc) error {
text := firstMCPClientInstallText(textFuncs)
root, err := readClaudeCodeConfig(configPath, text)
if err != nil {
return err
}
mcpServers, err := ensureJSONMap(root, "mcpServers", text)
if err != nil {
return err
}
mcpServers[strings.TrimSpace(serverID)] = map[string]any{
"type": serverConfig.Type,
"command": serverConfig.Command,
"args": append([]string(nil), serverConfig.Args...),
"env": cloneStringMap(serverConfig.Env),
}
root["mcpServers"] = mcpServers
data, err := json.MarshalIndent(root, "", " ")
if err != nil {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_serialize_failed", map[string]any{"detail": err.Error()}))
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_dir_create_failed", map[string]any{"detail": err.Error()}))
}
if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_write_failed", map[string]any{"detail": err.Error()}))
}
return nil
}
func readClaudeCodeConfig(configPath string, textFuncs ...mcpClientInstallTextFunc) (map[string]any, error) {
text := firstMCPClientInstallText(textFuncs)
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return map[string]any{}, nil
}
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_read_failed", map[string]any{"detail": err.Error()}))
}
if strings.TrimSpace(string(data)) == "" {
return map[string]any{}, nil
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_parse_failed", map[string]any{"detail": err.Error()}))
}
if root == nil {
return map[string]any{}, nil
}
return root, nil
}
func ensureJSONMap(root map[string]any, key string, textFuncs ...mcpClientInstallTextFunc) (map[string]any, error) {
text := firstMCPClientInstallText(textFuncs)
if root == nil {
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": "JSON root", "expected": "an object"}))
}
value, exists := root[key]
if !exists || value == nil {
result := map[string]any{}
root[key] = result
return result, nil
}
typed, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": key, "expected": "an object"}))
}
return typed, nil
}
func readCodexMCPServerConfig(configPath string, serverID string, textFuncs ...mcpClientInstallTextFunc) (codexMCPServerConfig, bool, error) {
text := firstMCPClientInstallText(textFuncs)
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return codexMCPServerConfig{}, false, nil
}
return codexMCPServerConfig{}, false, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_read_failed", map[string]any{"detail": err.Error()}))
}
return parseCodexMCPServerConfig(string(data), serverID, textFuncs...)
}
func upsertCodexMCPServerConfig(configPath string, serverID string, serverConfig codexMCPServerConfig, textFuncs ...mcpClientInstallTextFunc) error {
text := firstMCPClientInstallText(textFuncs)
data, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_read_failed", map[string]any{"detail": err.Error()}))
}
updated := replaceOrAppendCodexMCPServerBlock(string(data), strings.TrimSpace(serverID), renderCodexMCPServerBlock(serverID, serverConfig))
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_dir_create_failed", map[string]any{"detail": err.Error()}))
}
if err := os.WriteFile(configPath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_write_failed", map[string]any{"detail": err.Error()}))
}
return nil
}
func renderCodexMCPServerBlock(serverID string, serverConfig codexMCPServerConfig) string {
trimmedID := strings.TrimSpace(serverID)
if trimmedID == "" {
trimmedID = gonaviMCPServerID
}
lines := []string{
fmt.Sprintf("[mcp_servers.%s]", trimmedID),
fmt.Sprintf("command = %s", tomlString(serverConfig.Command)),
fmt.Sprintf("args = [%s]", strings.Join(renderTomlStringArray(serverConfig.Args), ", ")),
}
if serverConfig.StartupTimeoutSec > 0 {
lines = append(lines, fmt.Sprintf("startup_timeout_sec = %d", serverConfig.StartupTimeoutSec))
}
return strings.Join(lines, "\n") + "\n"
}
func parseCodexMCPServerConfig(content string, serverID string, textFuncs ...mcpClientInstallTextFunc) (codexMCPServerConfig, bool, error) {
text := firstMCPClientInstallText(textFuncs)
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
mainHeader := fmt.Sprintf("[mcp_servers.%s]", strings.TrimSpace(serverID))
result := codexMCPServerConfig{}
found := false
inside := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !inside {
if trimmed == mainHeader {
inside = true
found = true
}
continue
}
if isTOMLHeaderLine(trimmed) {
break
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, value, ok := splitTOMLAssignment(trimmed)
if !ok {
continue
}
switch key {
case "command":
parsed, err := parseTOMLString(value)
if err != nil {
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.command", strings.TrimSpace(serverID)), "expected": "a TOML string"}))
}
result.Command = parsed
case "args":
parsed, err := parseTOMLStringArray(value)
if err != nil {
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.args", strings.TrimSpace(serverID)), "expected": "a TOML string array"}))
}
result.Args = parsed
case "startup_timeout_sec":
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.startup_timeout_sec", strings.TrimSpace(serverID)), "expected": "an integer"}))
}
result.StartupTimeoutSec = parsed
}
}
return result, found, nil
}
func replaceOrAppendCodexMCPServerBlock(content string, serverID string, block string) string {
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
mainHeader := fmt.Sprintf("[mcp_servers.%s]", serverID)
nestedPrefix := fmt.Sprintf("[mcp_servers.%s.", serverID)
start, end := -1, -1
for index, line := range lines {
trimmed := strings.TrimSpace(line)
if start == -1 {
if trimmed == mainHeader || strings.HasPrefix(trimmed, nestedPrefix) {
start = index
}
continue
}
if isTOMLHeaderLine(trimmed) && trimmed != mainHeader && !strings.HasPrefix(trimmed, nestedPrefix) {
end = index
break
}
}
if start != -1 && end == -1 {
end = len(lines)
}
rendered := strings.TrimRight(block, "\n")
if start == -1 {
base := strings.TrimSpace(strings.Join(lines, "\n"))
if base == "" {
return rendered + "\n"
}
return strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n\n" + rendered + "\n"
}
before := strings.TrimRight(strings.Join(lines[:start], "\n"), "\n")
after := strings.TrimLeft(strings.Join(lines[end:], "\n"), "\n")
switch {
case before == "" && after == "":
return rendered + "\n"
case before == "":
return rendered + "\n\n" + after
case after == "":
return before + "\n\n" + rendered + "\n"
default:
return before + "\n\n" + rendered + "\n\n" + after
}
}
func renderTomlStringArray(values []string) []string {
rendered := make([]string, 0, len(values))
for _, value := range values {
rendered = append(rendered, tomlString(value))
}
return rendered
}
func tomlString(value string) string {
if !strings.Contains(value, "'") && !strings.Contains(value, "\n") && !strings.Contains(value, "\r") {
return "'" + value + "'"
}
return strconv.Quote(value)
}
func splitTOMLAssignment(line string) (string, string, bool) {
index := strings.Index(line, "=")
if index <= 0 {
return "", "", false
}
key := strings.TrimSpace(line[:index])
value := strings.TrimSpace(line[index+1:])
if key == "" {
return "", "", false
}
return key, value, true
}
func parseTOMLString(value string) (string, error) {
value = strings.TrimSpace(value)
if len(value) < 2 {
return "", fmt.Errorf("invalid string format")
}
switch value[0] {
case '\'':
if value[len(value)-1] != '\'' {
return "", fmt.Errorf("single-quoted string is not closed")
}
return value[1 : len(value)-1], nil
case '"':
parsed, err := strconv.Unquote(value)
if err != nil {
return "", err
}
return parsed, nil
default:
return "", fmt.Errorf("not a string")
}
}
func parseTOMLStringArray(value string) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return []string{}, nil
}
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return nil, fmt.Errorf("not an array")
}
inner := strings.TrimSpace(value[1 : len(value)-1])
if inner == "" {
return []string{}, nil
}
result := make([]string, 0, 4)
for inner != "" {
item, rest, err := consumeTOMLQuotedString(inner)
if err != nil {
return nil, err
}
result = append(result, item)
inner = strings.TrimSpace(rest)
if inner == "" {
break
}
if !strings.HasPrefix(inner, ",") {
return nil, fmt.Errorf("invalid array separator")
}
inner = strings.TrimSpace(inner[1:])
}
return result, nil
}
func consumeTOMLQuotedString(value string) (string, string, error) {
value = strings.TrimLeft(value, " \t")
if value == "" {
return "", "", fmt.Errorf("string is empty")
}
switch value[0] {
case '\'':
end := strings.IndexByte(value[1:], '\'')
if end < 0 {
return "", "", fmt.Errorf("single-quoted string is not closed")
}
end++
return value[1:end], value[end+1:], nil
case '"':
escaped := false
for index := 1; index < len(value); index++ {
ch := value[index]
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
parsed, err := strconv.Unquote(value[:index+1])
if err != nil {
return "", "", err
}
return parsed, value[index+1:], nil
}
}
return "", "", fmt.Errorf("double-quoted string is not closed")
default:
return "", "", fmt.Errorf("not a string")
}
}
func decodeJSONLikeStringSlice(value any) ([]string, error) {
switch typed := value.(type) {
case nil:
return []string{}, nil
case []string:
return append([]string(nil), typed...), nil
case []any:
result := make([]string, 0, len(typed))
for _, item := range typed {
str, ok := item.(string)
if !ok {
return nil, fmt.Errorf("array element is not a string")
}
result = append(result, str)
}
return result, nil
default:
return nil, fmt.Errorf("not a string array")
}
}
func anyString(value any) string {
text, _ := value.(string)
return text
}
func sameMCPCommand(actualCommand string, actualArgs []string, expectedCommand string, expectedArgs []string) bool {
return strings.TrimSpace(actualCommand) == strings.TrimSpace(expectedCommand) &&
reflect.DeepEqual(normalizeStringSlice(actualArgs), normalizeStringSlice(expectedArgs))
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return []string{}
}
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, strings.TrimSpace(value))
}
return result
}
func isTOMLHeaderLine(line string) bool {
line = strings.TrimSpace(line)
return strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]")
}