返回 DeepSeek-Reasonix
mcp.go
1 package installsource
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "sort"
8 "strings"
9
10 "reasonix/internal/config"
11 fileencoding "reasonix/internal/fileutil/encoding"
12 )
13
14 // mcpEntryAction assembles the DTO for a single MCP server install. The
15 // caller decides whether apply=true actually runs cfg.UpsertPlugin +
16 // SaveTo + connectMCP.
17 func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action {
18 scope := t.installScope(req, "mcp", source)
19 // install_source is an explicit user action. Preserve that provenance for
20 // the live connector so a newly installed server is usable immediately,
21 // while still identifying project-scoped persistence accurately enough for
22 // the host to record the exact durable launch grant used on the next boot.
23 if scope == "project" {
24 e.Source = config.MCPSourceProjectConfig
25 } else {
26 e.Source = config.MCPSourceUserConfig
27 }
28 var normalizedCommand bool
29 e, normalizedCommand = config.NormalizePluginCommandLine(e)
30 // Tier comes from the call (req.Tier) or the entry; either way we
31 // validate. An unrecognised tier is silently downgraded to "background" by
32 // normalizeTier — we capture the original value to surface a warning.
33 desired := firstNonEmpty(req.Tier, e.Tier)
34 norm, ok := normalizeTier(desired)
35 e.Tier = norm
36 a := action{
37 Kind: "mcp",
38 Action: "install_mcp_server",
39 Name: e.Name,
40 Source: source,
41 ConfigPath: t.configPath(scope),
42 Scope: scope,
43 Transport: pluginTransport(e),
44 URL: e.URL,
45 Command: e.Command,
46 Args: e.Args,
47 Env: e.Env,
48 Headers: e.Headers,
49 entry: e,
50 }
51 if !ok && strings.TrimSpace(desired) != "" {
52 a.RiskReasons = append(a.RiskReasons, fmt.Sprintf("tier %q is unknown; treating as background", desired))
53 }
54 if normalizedCommand {
55 a.RiskReasons = append(a.RiskReasons, "split a pasted MCP command line into command and args")
56 }
57 a.RiskLevel, a.RiskReasons = mcpActionRisk(e, a.RiskReasons)
58 return a
59 }
60
61 // mcpActionRisk classifies a single MCP install. An entry with auth headers,
62 // an `eager` tier, or a package-name source is medium. An entry with auth
63 // material and an out-of-tree URL is high.
64 func mcpActionRisk(e config.PluginEntry, reasons []string) (RiskLevel, []string) {
65 level := RiskMedium
66 hasAuth := false
67 for k, v := range e.Headers {
68 if strings.EqualFold(k, "Authorization") || strings.Contains(strings.ToLower(v), "bearer") || strings.Contains(strings.ToLower(v), "token") {
69 hasAuth = true
70 reasons = append(reasons, "sends auth headers to "+e.URL)
71 }
72 }
73 if e.Tier == "eager" {
74 level = RiskHigh
75 reasons = append(reasons, "tier=eager blocks startup until the handshake completes")
76 }
77 if hasAuth && level == RiskMedium {
78 level = RiskHigh
79 }
80 return level, reasons
81 }
82
83 // remoteMCPAction builds a server entry from a URL alone. The default
84 // transport is http unless the URL's path smells like SSE.
85 func (t *installSourceTool) remoteMCPAction(req request, sourceURL string) action {
86 transport := req.Transport
87 if transport == "" || transport == "auto" {
88 transport = "http"
89 if strings.Contains(strings.ToLower(sourceURL), "sse") {
90 transport = "sse"
91 }
92 }
93 name := strings.TrimSpace(req.Name)
94 if name == "" {
95 name = mcpNameFromURL(sourceURL)
96 }
97 e := config.PluginEntry{
98 Name: name,
99 Type: transport,
100 URL: sourceURL,
101 Headers: cleanMap(req.Headers),
102 Tier: req.Tier,
103 }
104 return t.mcpEntryAction(req, e, sourceURL)
105 }
106
107 // localExecutableMCPAction treats a chmod +x'd local file as a stdio MCP
108 // server. By default the file itself becomes the command; callers may override
109 // the command to wrap the source with an interpreter.
110 func (t *installSourceTool) localExecutableMCPAction(req request, path string) action {
111 name := strings.TrimSpace(req.Name)
112 if name == "" {
113 name = sanitizeName(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
114 }
115 command := strings.TrimSpace(req.Command)
116 if command == "" {
117 command = path
118 }
119 e := config.PluginEntry{
120 Name: name,
121 Command: command,
122 Args: append([]string(nil), req.Args...),
123 Env: cleanMap(req.Env),
124 Tier: req.Tier,
125 }
126 return t.mcpEntryAction(req, e, path)
127 }
128
129 // packageMCPAction treats the source as an npm package name and constructs
130 // the canonical `npx -y <pkg>` invocation. The caller can override the
131 // command and args for non-npm package sources.
132 func (t *installSourceTool) packageMCPAction(req request) action {
133 name := strings.TrimSpace(req.Name)
134 if name == "" {
135 name = sanitizeName(strings.TrimPrefix(req.Source, "@"))
136 if i := strings.LastIndex(name, "/"); i >= 0 {
137 name = name[i+1:]
138 }
139 }
140 command := strings.TrimSpace(req.Command)
141 args := append([]string(nil), req.Args...)
142 if command == "" {
143 command = "npx"
144 args = []string{"-y", req.Source}
145 }
146 e := config.PluginEntry{
147 Name: name,
148 Command: command,
149 Args: args,
150 Env: cleanMap(req.Env),
151 Tier: req.Tier,
152 }
153 return t.mcpEntryAction(req, e, req.Source)
154 }
155
156 // readMCPJSON reads a .mcp.json file from disk and parses it. It is a
157 // convenience wrapper used by planLocal; the parser itself is exported as
158 // parseMCPJSON for tests.
159 func readMCPJSON(path string) ([]config.PluginEntry, []string, error) {
160 b, err := fileencoding.ReadFileUTF8(path)
161 if err != nil {
162 return nil, nil, err
163 }
164 entries, warnings, err := parseMCPJSON(b)
165 if err != nil {
166 return nil, warnings, err
167 }
168 return entries, warnings, nil
169 }
170
171 // parseMCPJSON extracts mcpServers entries from a .mcp.json-style document.
172 // Warnings are returned for non-fatal anomalies (unknown tier values) and
173 // collected separately so a typo does not refuse an otherwise valid file.
174 func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) {
175 var raw struct {
176 MCPServers map[string]struct {
177 Type string `json:"type"`
178 Command string `json:"command"`
179 Args []string `json:"args"`
180 Env map[string]string `json:"env"`
181 URL string `json:"url"`
182 Headers map[string]string `json:"headers"`
183 AutoStart *bool `json:"auto_start"`
184 StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
185 CallTimeoutSeconds int `json:"call_timeout_seconds"`
186 ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
187 Tier string `json:"tier"`
188 } `json:"mcpServers"`
189 }
190 if err := json.Unmarshal(b, &raw); err != nil {
191 return nil, nil, newErr(ErrInvalidManifest, "could not parse .mcp.json: %v", err)
192 }
193 if len(raw.MCPServers) == 0 {
194 return nil, nil, newErr(ErrManifestMissing, ".mcp.json has no mcpServers")
195 }
196 names := make([]string, 0, len(raw.MCPServers))
197 for name := range raw.MCPServers {
198 names = append(names, name)
199 }
200 sort.Strings(names)
201 out := make([]config.PluginEntry, 0, len(names))
202 var warnings []string
203 for _, name := range names {
204 s := raw.MCPServers[name]
205 // Validate the raw transport before normalizeTransport gets a
206 // chance to silently map an unknown value to "auto"/"stdio".
207 rawType := strings.ToLower(strings.TrimSpace(s.Type))
208 if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" && rawType != "streamable-http" {
209 return nil, warnings, newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", name, s.Type)
210 }
211 typ := normalizeTransport(s.Type)
212 if typ == "auto" {
213 if strings.TrimSpace(s.URL) != "" {
214 typ = "http"
215 } else {
216 typ = "stdio"
217 }
218 }
219 tier, ok := normalizeTier(s.Tier)
220 if !ok && strings.TrimSpace(s.Tier) != "" {
221 warnings = append(warnings, fmt.Sprintf("%s: tier %q is unknown; treating as background", name, s.Tier))
222 }
223 e := config.PluginEntry{
224 Name: name,
225 Type: typ,
226 Command: strings.TrimSpace(s.Command),
227 Args: append([]string(nil), s.Args...),
228 Env: cleanMap(s.Env),
229 URL: strings.TrimSpace(s.URL),
230 Headers: cleanMap(s.Headers),
231 AutoStart: s.AutoStart,
232 StartupTimeoutSeconds: s.StartupTimeoutSeconds,
233 CallTimeoutSeconds: s.CallTimeoutSeconds,
234 ToolTimeoutSeconds: s.ToolTimeoutSeconds,
235 Tier: tier,
236 }
237 // An empty Type is the canonical "stdio" form; keep it that way so
238 // the rest of the config layer (UpsertPlugin / ShouldAutoStart)
239 // can treat "" and "stdio" identically.
240 if e.Type == "stdio" {
241 e.Type = ""
242 }
243 normalized, changed := config.NormalizePluginCommandLine(e)
244 e = normalized
245 if changed {
246 warnings = append(warnings, fmt.Sprintf("%s: split a pasted MCP command line into command and args", name))
247 }
248 if err := validateMCPEntry(e); err != nil {
249 return nil, warnings, err
250 }
251 out = append(out, e)
252 }
253 return out, warnings, nil
254 }
255
256 // validateMCPEntry enforces the per-transport required fields. It is the
257 // last line of defense before a server entry is persisted.
258 func validateMCPEntry(e config.PluginEntry) error {
259 name := strings.TrimSpace(e.Name)
260 if name == "" {
261 return newErr(ErrInvalidManifest, "MCP server name is required")
262 }
263 if !config.IsValidSkillName(name) {
264 return newErr(ErrInvalidManifest, "MCP server name %q is invalid; use letters, digits, '.', '_', or '-', starting with a letter or digit, up to 64 characters", e.Name)
265 }
266 // Reject explicit unknown transports. Without this check, normalizeTransport
267 // would silently map "carrier-pigeon" -> "auto" -> "stdio" and the entry
268 // would be persisted with the wrong effective type.
269 rawType := strings.ToLower(strings.TrimSpace(e.Type))
270 if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" {
271 return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type)
272 }
273 switch pluginTransport(e) {
274 case "stdio":
275 if strings.TrimSpace(e.Command) == "" {
276 return newErr(ErrInvalidManifest, "MCP server %q is stdio but has no command", e.Name)
277 }
278 case "http", "sse":
279 if strings.TrimSpace(e.URL) == "" {
280 return newErr(ErrInvalidManifest, "MCP server %q is remote but has no url", e.Name)
281 }
282 default:
283 return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type)
284 }
285 return nil
286 }
287
287 lines GO