返回 DeepSeek-Reasonix
mcp_command.go
根目录 / internal / config / mcp_command.go
1 package config
2
3 import (
4 "path/filepath"
5 "strings"
6 "unicode"
7
8 "reasonix/internal/shellparse"
9 )
10
11 // NormalizePluginCommandLine repairs the common MCP copy/paste mistake where a
12 // tutorial's full command line is placed in command while args is left empty.
13 // Valid commands that are just paths with spaces are left untouched when they
14 // look path-like; ordinary custom commands such as "custom-mcp --stdio" still
15 // split so the executable and arguments survive GUI/legacy normalization.
16 func NormalizePluginCommandLine(e PluginEntry) (PluginEntry, bool) {
17 if pluginEntryTransport(e) != "stdio" || len(e.Args) > 0 {
18 e.Command = strings.TrimSpace(e.Command)
19 return e, false
20 }
21 cmd := strings.TrimSpace(e.Command)
22 e.Command = cmd
23 if !strings.ContainsAny(cmd, " \t\r\n") {
24 return e, false
25 }
26 parts, ok := splitPluginCommandLine(cmd)
27 if !ok || len(parts) < 2 || !shouldSplitPluginCommand(cmd, parts[0]) {
28 return e, false
29 }
30 e.Command = parts[0]
31 e.Args = parts[1:]
32 return e, true
33 }
34
35 func normalizePluginCommandLines(c *Config) {
36 if c == nil {
37 return
38 }
39 for i := range c.Plugins {
40 c.Plugins[i], _ = NormalizePluginCommandLine(c.Plugins[i])
41 }
42 }
43
44 func pluginEntryTransport(e PluginEntry) string {
45 switch strings.ToLower(strings.TrimSpace(e.Type)) {
46 case "", "stdio":
47 return "stdio"
48 case "http", "streamable-http":
49 return "http"
50 case "sse":
51 return "sse"
52 default:
53 return strings.ToLower(strings.TrimSpace(e.Type))
54 }
55 }
56
57 func shouldSplitPluginCommand(original, first string) bool {
58 trimmed := strings.TrimLeftFunc(original, unicode.IsSpace)
59 if strings.HasPrefix(trimmed, `"`) || strings.HasPrefix(trimmed, `'`) {
60 return true
61 }
62 return knownMCPCommandRunner(first) || !hasPathSeparator(first)
63 }
64
65 func hasPathSeparator(s string) bool {
66 return strings.ContainsAny(s, `/\`)
67 }
68
69 func knownMCPCommandRunner(command string) bool {
70 base := commandBase(command)
71 base = strings.ToLower(base)
72 for _, ext := range []string{".exe", ".cmd", ".bat", ".ps1"} {
73 base = strings.TrimSuffix(base, ext)
74 }
75 switch base {
76 case "npx", "npm", "node", "pnpm", "yarn", "bun",
77 "uvx", "uv", "python", "python3", "py",
78 "docker", "deno", "go", "cmd", "powershell", "pwsh":
79 return true
80 default:
81 return false
82 }
83 }
84
85 func commandBase(command string) string {
86 command = strings.ReplaceAll(command, `\`, `/`)
87 return filepath.Base(command)
88 }
89
90 func splitPluginCommandLine(s string) ([]string, bool) {
91 if preservesUnquotedWindowsPath(s) {
92 return nil, false
93 }
94 fields, malformed := shellparse.StaticFields(s)
95 if malformed != "" {
96 return nil, false
97 }
98 return fields, true
99 }
100
101 func preservesUnquotedWindowsPath(s string) bool {
102 trimmed := strings.TrimLeftFunc(s, unicode.IsSpace)
103 if strings.HasPrefix(trimmed, `"`) || strings.HasPrefix(trimmed, `'`) {
104 return false
105 }
106 first, _, _ := strings.Cut(trimmed, " ")
107 first, _, _ = strings.Cut(first, "\t")
108 return strings.Contains(first, `\`) && strings.ContainsAny(trimmed, " \t\r\n")
109 }
110
110 lines GO