返回 DeepSeek-Reasonix
mcpjson.go
根目录 / internal / config / mcpjson.go
1 package config
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9 "sort"
10 "strings"
11
12 "reasonix/internal/fileutil"
13 fileencoding "reasonix/internal/fileutil/encoding"
14 "reasonix/internal/mcpdiag"
15 )
16
17 // mcpJSONFile is the project-root file Claude Code calls .mcp.json. Reasonix reads
18 // it so an MCP server already configured for Claude works here unchanged — the
19 // server specs map field-for-field onto PluginEntry.
20 const mcpJSONFile = ".mcp.json"
21
22 // mcpServerSpec mirrors one entry of Claude Code's "mcpServers" map. The field
23 // names and semantics match PluginEntry: command/args/env describe a local
24 // stdio server; type/url/headers describe a remote one. Reasonix also accepts
25 // startup and call timeout fields as Reasonix policy extensions.
26 type mcpServerSpec struct {
27 Type string `json:"type"`
28 Command string `json:"command"`
29 Args []string `json:"args"`
30 Env map[string]string `json:"env"`
31 URL string `json:"url"`
32 Headers map[string]string `json:"headers"`
33 StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
34 CallTimeoutSeconds int `json:"call_timeout_seconds"`
35 ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
36 AutoStart *bool `json:"auto_start"`
37 }
38
39 // loadMCPJSON reads path (Claude Code's .mcp.json) and returns its servers as
40 // PluginEntry values, sorted by name for a stable connection order. An absent
41 // file is not an error (returns nil, nil). A present-but-malformed file is an
42 // error so a typo surfaces loudly instead of silently dropping every server.
43 func loadMCPJSON(path string) ([]PluginEntry, error) {
44 resolved, err := resolveConfigAccessPath(path, false)
45 if err != nil {
46 return nil, fmt.Errorf("mcp config %s: %w", path, err)
47 }
48 b, err := fileencoding.ReadFileUTF8(resolved)
49 if os.IsNotExist(err) {
50 return nil, nil
51 }
52 if err != nil {
53 return nil, fmt.Errorf("mcp config %s: %w", path, err)
54 }
55 var doc struct {
56 MCPServers map[string]mcpServerSpec `json:"mcpServers"`
57 }
58 if err := json.Unmarshal(b, &doc); err != nil {
59 return nil, fmt.Errorf("mcp config %s: %w", path, err)
60 }
61 return specsToEntries(doc.MCPServers, nil), nil
62 }
63
64 // LoadMCPJSONPlugin returns one server entry from a Claude-compatible .mcp.json.
65 func LoadMCPJSONPlugin(path, name string) (PluginEntry, bool, error) {
66 entries, err := loadMCPJSON(path)
67 if err != nil {
68 return PluginEntry{}, false, err
69 }
70 for _, entry := range entries {
71 if entry.Name == name {
72 return entry, true, nil
73 }
74 }
75 return PluginEntry{}, false, nil
76 }
77
78 // specsToEntries converts an mcpServers map to PluginEntry values, sorted by name
79 // for a stable connection order. Names in skip are dropped (used for v0.x's
80 // mcpDisabled list).
81 func specsToEntries(specs map[string]mcpServerSpec, skip map[string]bool) []PluginEntry {
82 names := make([]string, 0, len(specs))
83 for name := range specs {
84 if !skip[name] {
85 names = append(names, name)
86 }
87 }
88 sort.Strings(names)
89 entries := make([]PluginEntry, 0, len(names))
90 for _, name := range names {
91 entry := pluginEntryFromMCPSpec(name, specs[name])
92 entry.Source = MCPSourceProjectMCPJSON
93 entries = append(entries, entry)
94 }
95 return entries
96 }
97
98 // legacyConfigPath is the v0.x (TypeScript line) config file, ~/.reasonix/config.json.
99 func legacyConfigPath() string {
100 if IsolatedHomeDir() != "" {
101 return ""
102 }
103 home, err := os.UserHomeDir()
104 if err != nil {
105 return ""
106 }
107 return filepath.Join(home, ".reasonix", "config.json")
108 }
109
110 // loadLegacyMCP reads the v0.x ~/.reasonix/config.json and returns its enabled
111 // MCP servers as PluginEntry values — both the canonical mcpServers map and the
112 // older `mcp` string list (mcpServers wins on a name collision, matching v0.x;
113 // servers listed in mcpDisabled are skipped) — so upgrading from v0.x keeps MCP
114 // servers working without rewriting them as [[plugins]]. Absent or malformed →
115 // nil: a stale legacy file must never block startup, and it is the
116 // lowest-priority source anyway (the v2 config and .mcp.json win on a name
117 // collision — see Load).
118 func loadLegacyMCP(path string) []PluginEntry {
119 if path == "" {
120 return nil
121 }
122 b, err := fileencoding.ReadFileUTF8(path)
123 if err != nil {
124 return nil
125 }
126 var doc struct {
127 MCP []string `json:"mcp"`
128 MCPServers map[string]mcpServerSpec `json:"mcpServers"`
129 MCPEnv map[string]map[string]string `json:"mcpEnv"`
130 MCPDisabled []string `json:"mcpDisabled"`
131 }
132 if err := json.Unmarshal(b, &doc); err != nil {
133 return nil
134 }
135 disabled := make(map[string]bool, len(doc.MCPDisabled))
136 for _, n := range doc.MCPDisabled {
137 disabled[n] = true
138 }
139 entries := specsToEntries(doc.MCPServers, disabled)
140 have := make(map[string]bool, len(entries))
141 for _, e := range entries {
142 have[e.Name] = true
143 }
144 for i, raw := range doc.MCP {
145 pe, ok := parseLegacyMCPSpec(raw)
146 if !ok || disabled[pe.Name] {
147 continue
148 }
149 if pe.Name == "" {
150 pe.Name = anonymousMCPName(i)
151 } else if pe.Command != "" {
152 pe.Env = doc.MCPEnv[pe.Name]
153 }
154 if have[pe.Name] {
155 continue
156 }
157 have[pe.Name] = true
158 pe, _ = NormalizePluginCommandLine(pe)
159 entries = append(entries, pe)
160 }
161 for i := range entries {
162 entries[i].Source = MCPSourceLegacyUser
163 }
164 return entries
165 }
166
167 var legacyMCPSpecName = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)=(.*)$`)
168
169 // parseLegacyMCPSpec parses one v0.x `--mcp`-format string: "name=cmd args...",
170 // "name=https://url" (SSE), or "name=streamable+https://url" (streamable HTTP);
171 // the name= prefix is optional.
172 func parseLegacyMCPSpec(raw string) (PluginEntry, bool) {
173 body := strings.TrimSpace(raw)
174 var name string
175 if m := legacyMCPSpecName.FindStringSubmatch(body); m != nil {
176 name, body = m[1], strings.TrimSpace(m[2])
177 }
178 if body == "" {
179 return PluginEntry{}, false
180 }
181 lower := strings.ToLower(body)
182 if strings.HasPrefix(lower, "streamable+http://") || strings.HasPrefix(lower, "streamable+https://") {
183 return PluginEntry{Name: name, Type: "http", URL: body[len("streamable+"):]}, true
184 }
185 if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
186 return PluginEntry{Name: name, Type: "sse", URL: body}, true
187 }
188 parts, ok := splitPluginCommandLine(body)
189 if !ok || len(parts) == 0 {
190 return PluginEntry{}, false
191 }
192 if shouldSplitPluginCommand(body, parts[0]) {
193 return PluginEntry{Name: name, Command: parts[0], Args: parts[1:]}, true
194 }
195 return PluginEntry{Name: name, Command: body}, true
196 }
197
198 // anonymousMCPName names a v0.x spec that carried no name= prefix (its tools
199 // registered unprefixed in v0.x; v1+ plugins require a name).
200 func anonymousMCPName(i int) string {
201 return fmt.Sprintf("mcp-%d", i+1)
202 }
203
204 func pluginEntryFromMCPSpec(name string, s mcpServerSpec) PluginEntry {
205 e := PluginEntry{
206 Name: name,
207 Type: s.Type,
208 Command: s.Command,
209 Args: s.Args,
210 Env: s.Env,
211 URL: s.URL,
212 Headers: s.Headers,
213 StartupTimeoutSeconds: s.StartupTimeoutSeconds,
214 CallTimeoutSeconds: s.CallTimeoutSeconds,
215 ToolTimeoutSeconds: s.ToolTimeoutSeconds,
216 AutoStart: s.AutoStart,
217 }
218 e, _ = NormalizePluginCommandLine(e)
219 return e
220 }
221
222 // mergeMCPJSON appends servers from .mcp.json that the TOML config did not
223 // already declare. reasonix.toml's [[plugins]] win on a name collision: it is the
224 // Reasonix-specific, more explicit of the two, so it overrides the shared,
225 // checked-in .mcp.json rather than the other way round.
226 func (c *Config) mergeMCPJSON(entries []PluginEntry) {
227 index := make(map[string]int, len(c.Plugins))
228 for i, p := range c.Plugins {
229 index[p.Name] = i
230 }
231 for _, e := range entries {
232 if i, exists := index[e.Name]; exists {
233 // Project configuration always wins over user-global configuration.
234 // Within one project, reasonix.toml remains more specific than the
235 // Claude-compatible .mcp.json file.
236 if e.Source == MCPSourceProjectMCPJSON && !c.Plugins[i].Source.ProjectScoped() {
237 c.Plugins[i] = e
238 }
239 continue
240 }
241 index[e.Name] = len(c.Plugins)
242 c.Plugins = append(c.Plugins, e)
243 }
244 }
245
246 // UpsertMCPJSONPlugin writes one MCP server to a Claude-compatible .mcp.json
247 // file, preserving unrelated top-level fields and unknown per-server fields.
248 func UpsertMCPJSONPlugin(path string, entry PluginEntry) (bool, error) {
249 entry, _ = NormalizePluginCommandLine(entry)
250 if err := validatePlugin(entry); err != nil {
251 return false, err
252 }
253 root, servers, err := readMCPJSONRaw(path)
254 if err != nil {
255 return false, err
256 }
257 raw, existed := servers[entry.Name]
258 server := map[string]json.RawMessage{}
259 if existed && len(raw) > 0 {
260 if err := json.Unmarshal(raw, &server); err != nil || server == nil {
261 return false, fmt.Errorf("mcp config %s: server %q is not an object", path, entry.Name)
262 }
263 }
264 if err := applyPluginEntryToMCPJSONServer(server, entry); err != nil {
265 return false, fmt.Errorf("mcp config %s: server %q: %w", path, entry.Name, err)
266 }
267 updatedRaw, err := json.Marshal(server)
268 if err != nil {
269 return false, fmt.Errorf("mcp config %s: server %q: %w", path, entry.Name, err)
270 }
271 servers[entry.Name] = updatedRaw
272 if err := writeMCPJSONServers(path, root, servers); err != nil {
273 return false, err
274 }
275 return !existed, nil
276 }
277
278 // RemoveMCPJSONPlugin removes one MCP server from a Claude-compatible .mcp.json
279 // file. Missing files or missing entries are reported as unchanged.
280 func RemoveMCPJSONPlugin(path, name string) (bool, error) {
281 root, servers, err := readMCPJSONRaw(path)
282 if os.IsNotExist(err) {
283 return false, nil
284 }
285 if err != nil {
286 return false, err
287 }
288 if _, ok := servers[name]; !ok {
289 return false, nil
290 }
291 delete(servers, name)
292 if err := writeMCPJSONServers(path, root, servers); err != nil {
293 return false, err
294 }
295 return true, nil
296 }
297
298 func readMCPJSONRaw(path string) (map[string]json.RawMessage, map[string]json.RawMessage, error) {
299 root := map[string]json.RawMessage{}
300 servers := map[string]json.RawMessage{}
301 resolved, err := resolveConfigAccessPath(path, false)
302 if err != nil {
303 return nil, nil, fmt.Errorf("mcp config %s: %w", path, err)
304 }
305 b, err := fileencoding.ReadFileUTF8(resolved)
306 if os.IsNotExist(err) {
307 return root, servers, nil
308 }
309 if err != nil {
310 return nil, nil, fmt.Errorf("mcp config %s: %w", path, err)
311 }
312 if err := json.Unmarshal(b, &root); err != nil {
313 return nil, nil, fmt.Errorf("mcp config %s: %w", path, err)
314 }
315 raw, ok := root["mcpServers"]
316 if !ok || len(raw) == 0 || string(raw) == "null" {
317 return root, servers, nil
318 }
319 if err := json.Unmarshal(raw, &servers); err != nil || servers == nil {
320 return nil, nil, fmt.Errorf("mcp config %s: mcpServers must be an object", path)
321 }
322 return root, servers, nil
323 }
324
325 func applyPluginEntryToMCPJSONServer(server map[string]json.RawMessage, entry PluginEntry) error {
326 transport := strings.ToLower(strings.TrimSpace(entry.Type))
327 if transport == "" {
328 transport = "stdio"
329 }
330 if transport == "stdio" {
331 delete(server, "type")
332 setMCPJSONString(server, "command", strings.TrimSpace(entry.Command))
333 setMCPJSONStringArray(server, "args", entry.Args)
334 setMCPJSONStringMap(server, "env", entry.Env)
335 delete(server, "url")
336 delete(server, "headers")
337 } else {
338 setMCPJSONString(server, "type", transport)
339 setMCPJSONString(server, "url", strings.TrimSpace(entry.URL))
340 setMCPJSONStringMap(server, "headers", entry.Headers)
341 setMCPJSONStringMap(server, "env", entry.Env)
342 delete(server, "command")
343 delete(server, "args")
344 }
345 setMCPJSONInt(server, "startup_timeout_seconds", entry.StartupTimeoutSeconds)
346 setMCPJSONInt(server, "call_timeout_seconds", entry.CallTimeoutSeconds)
347 setMCPJSONIntMap(server, "tool_timeout_seconds", entry.ToolTimeoutSeconds)
348 // The removed per-tool reader list is accepted on load for compatibility but
349 // never persisted. Explicitly delete it when updating an existing shared
350 // .mcp.json entry so the obsolete setting disappears naturally.
351 delete(server, "trusted_read_only_tools")
352 delete(server, "default_tools_approval_mode")
353 delete(server, "approvals_reviewer")
354 setMCPJSONBool(server, "auto_start", entry.AutoStart)
355 if err := removeMCPJSONApprovalModes(server); err != nil {
356 return err
357 }
358 return nil
359 }
360
361 func removeMCPJSONApprovalModes(server map[string]json.RawMessage) error {
362 const key = "tools"
363 tools := map[string]json.RawMessage{}
364 if raw, ok := server[key]; ok && len(raw) > 0 && strings.TrimSpace(string(raw)) != "null" {
365 if err := json.Unmarshal(raw, &tools); err != nil || tools == nil {
366 return fmt.Errorf("%s must be an object", key)
367 }
368 }
369
370 // Remove Reasonix's retired approval_mode while preserving tool fields owned
371 // by other MCP clients.
372 for name, raw := range tools {
373 var fields map[string]json.RawMessage
374 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
375 continue
376 }
377 delete(fields, "approval_mode")
378 if len(fields) == 0 {
379 delete(tools, name)
380 continue
381 }
382 updated, err := json.Marshal(fields)
383 if err != nil {
384 return fmt.Errorf("%s[%q]: %w", key, name, err)
385 }
386 tools[name] = updated
387 }
388
389 if len(tools) == 0 {
390 delete(server, key)
391 return nil
392 }
393 raw, err := json.Marshal(tools)
394 if err != nil {
395 return fmt.Errorf("%s: %w", key, err)
396 }
397 server[key] = raw
398 return nil
399 }
400
401 func writeMCPJSONServers(path string, root map[string]json.RawMessage, servers map[string]json.RawMessage) error {
402 serversRaw, err := json.Marshal(servers)
403 if err != nil {
404 return fmt.Errorf("mcp config %s: %w", path, err)
405 }
406 root["mcpServers"] = serversRaw
407 return writeMCPJSON(path, root)
408 }
409
410 func clearMCPJSONAuthentication(path, name string) (PluginEntry, bool, error) {
411 root, servers, err := readMCPJSONRaw(path)
412 if err != nil {
413 return PluginEntry{}, false, err
414 }
415 raw, ok := servers[name]
416 if !ok {
417 return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
418 }
419 var spec mcpServerSpec
420 if err := json.Unmarshal(raw, &spec); err != nil {
421 return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err)
422 }
423 cleanHeaders, cleanEnv, cleanURL, changed := mcpdiag.ClearAuthConfig(spec.Headers, spec.Env, spec.URL)
424 if !changed {
425 return pluginEntryFromMCPSpec(name, spec), false, nil
426 }
427 spec.Headers = cleanHeaders
428 spec.Env = cleanEnv
429 spec.URL = cleanURL
430
431 var server map[string]json.RawMessage
432 if err := json.Unmarshal(raw, &server); err != nil || server == nil {
433 return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q is not an object", path, name)
434 }
435 setMCPJSONStringMap(server, "headers", cleanHeaders)
436 setMCPJSONStringMap(server, "env", cleanEnv)
437 setMCPJSONString(server, "url", cleanURL)
438 updatedRaw, err := json.Marshal(server)
439 if err != nil {
440 return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err)
441 }
442 servers[name] = updatedRaw
443 serversRaw, err := json.Marshal(servers)
444 if err != nil {
445 return PluginEntry{}, false, fmt.Errorf("mcp config %s: %w", path, err)
446 }
447 root["mcpServers"] = serversRaw
448 if err := writeMCPJSON(path, root); err != nil {
449 return PluginEntry{}, false, err
450 }
451 return pluginEntryFromMCPSpec(name, spec), true, nil
452 }
453
454 func setMCPJSONStringMap(server map[string]json.RawMessage, key string, values map[string]string) {
455 if len(values) == 0 {
456 delete(server, key)
457 return
458 }
459 raw, err := json.Marshal(values)
460 if err != nil {
461 delete(server, key)
462 return
463 }
464 server[key] = raw
465 }
466
467 func setMCPJSONString(server map[string]json.RawMessage, key, value string) {
468 if value == "" {
469 delete(server, key)
470 return
471 }
472 raw, err := json.Marshal(value)
473 if err != nil {
474 delete(server, key)
475 return
476 }
477 server[key] = raw
478 }
479
480 func setMCPJSONStringArray(server map[string]json.RawMessage, key string, values []string) {
481 if len(values) == 0 {
482 delete(server, key)
483 return
484 }
485 raw, err := json.Marshal(values)
486 if err != nil {
487 delete(server, key)
488 return
489 }
490 server[key] = raw
491 }
492
493 func setMCPJSONInt(server map[string]json.RawMessage, key string, value int) {
494 if value <= 0 {
495 delete(server, key)
496 return
497 }
498 raw, err := json.Marshal(value)
499 if err != nil {
500 delete(server, key)
501 return
502 }
503 server[key] = raw
504 }
505
506 func setMCPJSONIntMap(server map[string]json.RawMessage, key string, values map[string]int) {
507 clean := make(map[string]int, len(values))
508 for k, v := range values {
509 if strings.TrimSpace(k) == "" || v <= 0 {
510 continue
511 }
512 clean[k] = v
513 }
514 if len(clean) == 0 {
515 delete(server, key)
516 return
517 }
518 raw, err := json.Marshal(clean)
519 if err != nil {
520 delete(server, key)
521 return
522 }
523 server[key] = raw
524 }
525
526 func setMCPJSONBool(server map[string]json.RawMessage, key string, value *bool) {
527 if value == nil {
528 delete(server, key)
529 return
530 }
531 raw, err := json.Marshal(*value)
532 if err != nil {
533 delete(server, key)
534 return
535 }
536 server[key] = raw
537 }
538
539 func writeMCPJSON(path string, root map[string]json.RawMessage) error {
540 out, err := json.MarshalIndent(root, "", " ")
541 if err != nil {
542 return fmt.Errorf("mcp config %s: %w", path, err)
543 }
544 out = append(out, '\n')
545 resolved, err := resolveConfigAccessPath(path, false)
546 if err != nil {
547 return fmt.Errorf("mcp config %s: %w", path, err)
548 }
549 dir := filepath.Dir(resolved)
550 if err := os.MkdirAll(dir, 0o755); err != nil {
551 return fmt.Errorf("mcp config %s: create dir: %w", path, err)
552 }
553 tmp, err := os.CreateTemp(dir, ".mcp.*.json.tmp")
554 if err != nil {
555 return fmt.Errorf("mcp config %s: create temp: %w", path, err)
556 }
557 tmpPath := tmp.Name()
558 if _, err := tmp.Write(out); err != nil {
559 tmp.Close()
560 os.Remove(tmpPath)
561 return fmt.Errorf("mcp config %s: write: %w", path, err)
562 }
563 if err := tmp.Close(); err != nil {
564 os.Remove(tmpPath)
565 return fmt.Errorf("mcp config %s: close temp: %w", path, err)
566 }
567 if err := fileutil.ReplaceFile(tmpPath, resolved); err != nil {
568 os.Remove(tmpPath)
569 return err
570 }
571 return nil
572 }
573
573 lines GO