返回 DeepSeek-Reasonix
mcp.go
根目录 / internal / cli / mcp.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/url"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14
15 "reasonix/internal/boot"
16 "reasonix/internal/config"
17 "reasonix/internal/mcpregistry"
18 "reasonix/internal/plugin"
19 )
20
21 // mcp.go holds the MCP server-management surface shared by the `reasonix mcp`
22 // subcommand (config-only; takes effect next session) and the in-chat `/mcp add`
23 // / `/mcp remove` slash commands (which hot-connect via the controller). Both
24 // parse arguments through parseMCPAdd so the grammar is identical everywhere.
25
26 // parseMCPAdd turns the arguments after "add" into a config.PluginEntry. Grammar:
27 //
28 // <name> [--http URL | --sse URL] [--env K=V]... [--header K=V]... [command [args...]]
29 //
30 // A --http/--sse URL makes it a remote server; otherwise the first non-flag token
31 // (after the name and any --env/--header flags) begins the stdio command, and the
32 // rest are its args verbatim — so the command keeps its own -flags (e.g. `npx -y
33 // pkg`). Flag values accept both "--http URL" and "--http=URL" forms.
34 func parseMCPAdd(args []string) (config.PluginEntry, error) {
35 var e config.PluginEntry
36 if len(args) == 0 {
37 return e, fmt.Errorf("mcp add: missing server name, command, or URL")
38 }
39
40 // Simplified forms:
41 // reasonix mcp add -- npx -y chrome-devtools-mcp@latest
42 // reasonix mcp add https://example.com/mcp
43 // keep the historical "name command..." form as well.
44 if args[0] == "--" {
45 if len(args) < 2 {
46 return e, fmt.Errorf("mcp add: -- requires a command argv")
47 }
48 e.Command = args[1]
49 e.Args = append([]string(nil), args[2:]...)
50 e.Name = defaultMCPNameFromArgv(e.Command, e.Args)
51 if e.Name == "" {
52 return e, fmt.Errorf("mcp add: could not derive a server name from the command; pass an explicit name")
53 }
54 return e, nil
55 }
56 if looksLikeRemoteMCPURL(args[0]) && (len(args) == 1 || strings.HasPrefix(args[1], "-")) {
57 e.Name = defaultMCPNameFromURL(args[0])
58 e.Type, e.URL = "http", args[0]
59 // Allow trailing --header/--env after a bare URL.
60 if len(args) > 1 {
61 restEntry, err := parseMCPAdd(append([]string{e.Name, "--http", args[0]}, args[1:]...))
62 if err != nil {
63 return e, err
64 }
65 return restEntry, nil
66 }
67 return e, nil
68 }
69
70 e.Name = strings.TrimSpace(args[0])
71 if e.Name == "" || strings.HasPrefix(e.Name, "-") {
72 return e, fmt.Errorf("mcp add: first argument must be the server name, got %q", args[0])
73 }
74 rest := args[1:]
75 if len(rest) > 0 && rest[0] == "--" {
76 // reasonix mcp add <name> -- <argv...>
77 if len(rest) < 2 {
78 return e, fmt.Errorf("mcp add: -- requires a command argv")
79 }
80 e.Command = rest[1]
81 e.Args = append([]string(nil), rest[2:]...)
82 return e, nil
83 }
84
85 i := 0
86 // next consumes the following token as a flag's value (for the "--flag value"
87 // form), reporting false when none remains.
88 next := func(flag string) (string, error) {
89 if i+1 >= len(rest) {
90 return "", fmt.Errorf("mcp add: %s needs a value", flag)
91 }
92 i++
93 return rest[i], nil
94 }
95 setEnv := func(dst *map[string]string, flag, pair string) error {
96 k, v, ok := strings.Cut(pair, "=")
97 if !ok || strings.TrimSpace(k) == "" {
98 return fmt.Errorf("mcp add: %s expects KEY=VALUE, got %q", flag, pair)
99 }
100 if *dst == nil {
101 *dst = map[string]string{}
102 }
103 (*dst)[k] = v
104 return nil
105 }
106
107 for ; i < len(rest); i++ {
108 a := rest[i]
109 key, inline, hasInline := strings.Cut(a, "=")
110 switch {
111 case !strings.HasPrefix(a, "-"):
112 // The stdio command and its remaining args, verbatim.
113 e.Command = a
114 e.Args = append([]string(nil), rest[i+1:]...)
115 i = len(rest)
116 case key == "--http" || key == "--streamable-http":
117 v := inline
118 if !hasInline {
119 var err error
120 if v, err = next(key); err != nil {
121 return e, err
122 }
123 }
124 e.Type, e.URL = "http", v
125 case key == "--sse":
126 v := inline
127 if !hasInline {
128 var err error
129 if v, err = next(key); err != nil {
130 return e, err
131 }
132 }
133 e.Type, e.URL = "sse", v
134 case key == "--env" || key == "--header":
135 pair := inline
136 if !hasInline {
137 var err error
138 if pair, err = next(key); err != nil {
139 return e, err
140 }
141 }
142 dst := &e.Env
143 if key == "--header" {
144 dst = &e.Headers
145 }
146 if err := setEnv(dst, key, pair); err != nil {
147 return e, err
148 }
149 default:
150 return e, fmt.Errorf("mcp add: unknown flag %q", a)
151 }
152 }
153
154 switch {
155 case e.URL != "" && e.Command != "":
156 return e, fmt.Errorf("mcp add: specify a command OR a --http/--sse URL, not both")
157 case e.URL == "" && e.Command == "":
158 return e, fmt.Errorf("mcp add: need a command (stdio) or a --http/--sse URL")
159 }
160 return e, nil
161 }
162
163 func looksLikeRemoteMCPURL(raw string) bool {
164 raw = strings.TrimSpace(raw)
165 return strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://")
166 }
167
168 func defaultMCPNameFromURL(raw string) string {
169 u, err := url.Parse(strings.TrimSpace(raw))
170 if err != nil || u.Host == "" {
171 return "remote-mcp"
172 }
173 host := strings.ToLower(u.Hostname())
174 host = strings.TrimPrefix(host, "www.")
175 host = strings.Split(host, ".")[0]
176 host = sanitizeMCPName(host)
177 if host == "" {
178 return "remote-mcp"
179 }
180 return host
181 }
182
183 func defaultMCPNameFromArgv(command string, args []string) string {
184 runner := strings.ToLower(strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filepath.Base(command), ".exe"), ".cmd"), ".bat"))
185 candidate := command
186 switch runner {
187 case "npx", "bunx", "uvx":
188 if operand := firstMCPCommandOperand(args); operand != "" {
189 candidate = operand
190 }
191 case "python", "python3", "py":
192 for i, arg := range args {
193 if arg == "-m" && i+1 < len(args) {
194 candidate = args[i+1]
195 break
196 }
197 }
198 if candidate == command {
199 if operand := firstMCPCommandOperand(args); operand != "" {
200 candidate = operand
201 }
202 }
203 case "node":
204 if operand := firstMCPCommandOperand(args); operand != "" {
205 candidate = operand
206 }
207 case "uv":
208 if len(args) > 0 && args[0] == "run" {
209 if operand := firstMCPCommandOperand(args[1:]); operand != "" {
210 candidate = operand
211 }
212 }
213 }
214 base := filepath.Base(candidate)
215 if at := strings.Index(base, "@"); at > 0 {
216 base = base[:at]
217 }
218 for _, ext := range []string{".js", ".exe", ".cmd", ".bat"} {
219 base = strings.TrimSuffix(base, ext)
220 }
221 name := sanitizeMCPName(base)
222 if name == "" {
223 return "mcp-server"
224 }
225 if candidate == command {
226 switch runner {
227 case "npx", "bunx", "uvx", "uv", "node", "python", "python3", "py":
228 return "mcp-server"
229 }
230 }
231 return name
232 }
233
234 func firstMCPCommandOperand(args []string) string {
235 valueFlags := map[string]bool{
236 "-p": true, "--package": true, "-c": true, "--call": true,
237 "--node-options": true, "--python": true,
238 }
239 options := true
240 for i := 0; i < len(args); i++ {
241 arg := strings.TrimSpace(args[i])
242 if options && arg == "--" {
243 options = false
244 continue
245 }
246 if options && strings.HasPrefix(arg, "-") {
247 if valueFlags[arg] {
248 i++
249 }
250 continue
251 }
252 if arg != "" {
253 return arg
254 }
255 }
256 return ""
257 }
258
259 func sanitizeMCPName(raw string) string {
260 raw = strings.ToLower(strings.TrimSpace(raw))
261 var b strings.Builder
262 for _, r := range raw {
263 switch {
264 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
265 b.WriteRune(r)
266 case r == '-' || r == '_' || r == '.':
267 b.WriteByte('-')
268 }
269 }
270 name := strings.Trim(b.String(), "-")
271 for strings.Contains(name, "--") {
272 name = strings.ReplaceAll(name, "--", "-")
273 }
274 return name
275 }
276
277 // tokenizeArgs splits a slash-command line into arguments, honouring "double" and
278 // 'single' quotes so values with spaces (e.g. --header "Authorization=Bearer x")
279 // survive. An unterminated quote takes the rest of the line as one token.
280 func tokenizeArgs(s string) []string {
281 var out []string
282 var cur strings.Builder
283 inWord := false
284 var quote rune
285 for _, r := range s {
286 switch {
287 case quote != 0:
288 if r == quote {
289 quote = 0
290 } else {
291 cur.WriteRune(r)
292 }
293 inWord = true
294 case r == '"' || r == '\'':
295 quote = r
296 inWord = true
297 case r == ' ' || r == '\t':
298 if inWord {
299 out = append(out, cur.String())
300 cur.Reset()
301 inWord = false
302 }
303 default:
304 cur.WriteRune(r)
305 inWord = true
306 }
307 }
308 if inWord {
309 out = append(out, cur.String())
310 }
311 return out
312 }
313
314 // mcpCommand implements persisted server management plus explicit browse/install
315 // access to the official MCP Registry. Config edits take effect on the next
316 // session start; for a live manual connection inside an open chat, use `/mcp add`.
317 func mcpCommand(args []string) int {
318 if len(args) == 0 {
319 mcpUsage()
320 return 2
321 }
322 switch args[0] {
323 case "list", "ls":
324 return mcpList()
325 case "add":
326 return mcpAddCLI(args[1:])
327 case "get":
328 return mcpGetCLI(args[1:])
329 case "remove", "rm":
330 return mcpRemoveCLI(args[1:])
331 case "enable":
332 return mcpEnableCLI(args[1:], true)
333 case "disable":
334 return mcpEnableCLI(args[1:], false)
335 case "retry", "connect":
336 // connect remains a compatibility alias for enable/retry.
337 return mcpRetryCLI(args[1:])
338 case "update":
339 return mcpUpdateCLI(args[1:])
340 case "import":
341 return mcpImportCLI()
342 case "browse", "search":
343 return mcpBrowseCLI(args[1:])
344 case "install":
345 return mcpInstallCLI(args[1:])
346 case "help", "-h", "--help":
347 mcpUsage()
348 return 0
349 default:
350 fmt.Fprintf(os.Stderr, "unknown mcp subcommand %q\n\n", args[0])
351 mcpUsage()
352 return 2
353 }
354 }
355
356 func defaultMCPRegistryClient() *mcpregistry.Client {
357 cachePath := ""
358 if cacheDir := config.CacheDir(); cacheDir != "" {
359 cachePath = filepath.Join(cacheDir, "mcp-registry-v0.1.json")
360 }
361 return mcpregistry.New(cachePath)
362 }
363
364 func mcpBrowseCLI(args []string) int {
365 return mcpBrowseWithClient(args, defaultMCPRegistryClient())
366 }
367
368 func mcpBrowseWithClient(args []string, client *mcpregistry.Client) int {
369 query := ""
370 limit := 20
371 jsonOutput := false
372 for i := 0; i < len(args); i++ {
373 switch args[i] {
374 case "--json":
375 jsonOutput = true
376 case "--limit":
377 if i+1 >= len(args) {
378 fmt.Fprintln(os.Stderr, "mcp browse: --limit needs a value")
379 return 2
380 }
381 i++
382 value, err := strconv.Atoi(args[i])
383 if err != nil || value <= 0 || value > 100 {
384 fmt.Fprintln(os.Stderr, "mcp browse: --limit must be between 1 and 100")
385 return 2
386 }
387 limit = value
388 default:
389 if strings.HasPrefix(args[i], "-") {
390 fmt.Fprintf(os.Stderr, "mcp browse: unknown flag %q\n", args[i])
391 return 2
392 }
393 if query != "" {
394 fmt.Fprintln(os.Stderr, "mcp browse: provide at most one search query")
395 return 2
396 }
397 query = args[i]
398 }
399 }
400 result, err := client.Search(context.Background(), query, limit)
401 if err != nil {
402 fmt.Fprintln(os.Stderr, err)
403 return 1
404 }
405 if result.Warning != "" {
406 fmt.Fprintf(os.Stderr, "MCP Registry unavailable; showing cached results: %s\n", result.Warning)
407 }
408 if jsonOutput {
409 encoder := json.NewEncoder(os.Stdout)
410 encoder.SetIndent("", " ")
411 if err := encoder.Encode(result.Entries); err != nil {
412 fmt.Fprintln(os.Stderr, err)
413 return 1
414 }
415 return 0
416 }
417 if len(result.Entries) == 0 {
418 fmt.Println("no MCP Registry servers matched")
419 return 0
420 }
421 for _, entry := range result.Entries {
422 status := entry.Transport
423 if !entry.Installable {
424 status = "manual setup: " + entry.UnavailableReason
425 }
426 title := entry.Title
427 if title == "" {
428 title = entry.Name
429 }
430 fmt.Printf("%s\t%s\t%s\t%s\n", entry.Name, entry.Version, status, title)
431 }
432 return 0
433 }
434
435 func mcpInstallCLI(args []string) int {
436 return mcpInstallWithClient(args, defaultMCPRegistryClient())
437 }
438
439 func mcpInstallWithClient(args []string, client *mcpregistry.Client) int {
440 if len(args) == 0 {
441 fmt.Fprintln(os.Stderr, "usage: reasonix mcp install <registry-name> [--as <local-name>]")
442 return 2
443 }
444 registryName := strings.TrimSpace(args[0])
445 if registryName == "" || strings.HasPrefix(registryName, "-") {
446 fmt.Fprintln(os.Stderr, "mcp install: registry server name is required")
447 return 2
448 }
449 localName := ""
450 for i := 1; i < len(args); i++ {
451 switch args[i] {
452 case "--as":
453 if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" {
454 fmt.Fprintln(os.Stderr, "mcp install: --as needs a local name")
455 return 2
456 }
457 i++
458 localName = strings.TrimSpace(args[i])
459 default:
460 fmt.Fprintf(os.Stderr, "mcp install: unknown argument %q\n", args[i])
461 return 2
462 }
463 }
464 entry, result, err := client.Resolve(context.Background(), registryName)
465 if err != nil {
466 fmt.Fprintln(os.Stderr, err)
467 return 1
468 }
469 if result.Warning != "" {
470 fmt.Fprintf(os.Stderr, "MCP Registry unavailable; using cached result: %s\n", result.Warning)
471 }
472 pluginEntry, err := entry.PluginEntry(localName)
473 if err != nil {
474 fmt.Fprintln(os.Stderr, err)
475 return 1
476 }
477 cfg, err := config.Load()
478 if err != nil {
479 fmt.Fprintln(os.Stderr, err)
480 return 1
481 }
482 for _, configured := range cfg.Plugins {
483 if configured.Name == pluginEntry.Name {
484 fmt.Fprintf(os.Stderr, "MCP server %q is already configured; choose another name with --as or remove it first\n", pluginEntry.Name)
485 return 1
486 }
487 }
488 installResult, probeErr := mcpProbeForInstall(pluginEntry)
489 if probeErr != nil && installResult.State != "action_required" {
490 fmt.Fprintf(os.Stderr, "MCP server %q was not installed: %s\n", pluginEntry.Name, installResult.Message)
491 return 1
492 }
493 if err := persistCLIInstalledMCP(mcpCLIWorkspaceRoot(), pluginEntry); err != nil {
494 fmt.Fprintln(os.Stderr, err)
495 return 1
496 }
497 if installResult.State == "action_required" {
498 fmt.Printf("installed MCP Registry server %q as %q — authentication required; finish authentication and run `reasonix mcp retry %s`\n", entry.Name, pluginEntry.Name, pluginEntry.Name)
499 return 0
500 }
501 fmt.Printf("installed MCP Registry server %q as %q — ready with %d tools\n", entry.Name, pluginEntry.Name, installResult.ToolCount)
502 return 0
503 }
504
505 func mcpEnableCLI(args []string, enabled bool) int {
506 if len(args) == 0 {
507 action := "enable"
508 if !enabled {
509 action = "disable"
510 }
511 fmt.Fprintf(os.Stderr, "usage: reasonix mcp %s <name>\n", action)
512 return 2
513 }
514 name := strings.TrimSpace(args[0])
515 workspace := mcpCLIWorkspaceRoot()
516 cfg, err := config.LoadForRoot(workspace)
517 if err != nil {
518 fmt.Fprintln(os.Stderr, err)
519 return 1
520 }
521 var entry config.PluginEntry
522 found := false
523 for _, p := range cfg.Plugins {
524 if p.Name == name {
525 entry = p
526 found = true
527 break
528 }
529 }
530 if !found {
531 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
532 return 1
533 }
534 store := config.DefaultMCPActivationStore()
535 if err := store.SetServerEnabled(entry, workspace, enabled); err != nil {
536 fmt.Fprintln(os.Stderr, err)
537 return 1
538 }
539 if enabled {
540 fmt.Printf("enabled MCP server %q — tools restore from cache; process starts on first call\n", name)
541 } else {
542 fmt.Printf("disabled MCP server %q — tools removed from the catalog; authorization retained\n", name)
543 }
544 return 0
545 }
546
547 func mcpRetryCLI(args []string) int {
548 if len(args) == 0 {
549 fmt.Fprintln(os.Stderr, "usage: reasonix mcp retry <name>")
550 return 2
551 }
552 // Standalone CLI cannot talk to a live Host; enabling is the durable
553 // equivalent of "retry next session". In-chat /mcp retry remains live.
554 return mcpEnableCLI(args, true)
555 }
556
557 func mcpUpdateCLI(args []string) int {
558 if len(args) == 0 {
559 fmt.Fprintln(os.Stderr, "usage: reasonix mcp update <name>")
560 return 2
561 }
562 name := strings.TrimSpace(args[0])
563 cfg, err := config.Load()
564 if err != nil {
565 fmt.Fprintln(os.Stderr, err)
566 return 1
567 }
568 var entry config.PluginEntry
569 found := false
570 for _, configured := range cfg.Plugins {
571 if configured.Name == name {
572 entry, found = configured, true
573 break
574 }
575 }
576 if !found {
577 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
578 return 1
579 }
580 result, probeErr := mcpProbeForInstall(entry)
581 if probeErr != nil {
582 fmt.Fprintf(os.Stderr, "MCP update for %q was not applied: %s\n", name, result.Message)
583 return 1
584 }
585 fmt.Printf("updated MCP server %q — candidate handshake passed with %d tools; cached schema switched atomically\n", name, result.ToolCount)
586 return 0
587 }
588
589 func mcpImportCLI() int {
590 total, added, updated, err := config.ImportCCSwitchMCP()
591 if err != nil {
592 fmt.Fprintln(os.Stderr, err)
593 return 1
594 }
595 fmt.Printf("imported %d MCP servers from cc-switch (%d added, %d updated) — servers load on the next session\n", total, added, updated)
596 return 0
597 }
598
599 func mcpList() int {
600 cfg, err := config.Load()
601 if err != nil {
602 fmt.Fprintln(os.Stderr, err)
603 return 1
604 }
605 listed := 0
606 for _, p := range cfg.Plugins {
607 typ := p.Type
608 if typ == "" {
609 typ = "stdio"
610 }
611 auto := ""
612 if !p.ShouldAutoStart() {
613 auto = " [auto_start=false]"
614 }
615 if typ == "stdio" {
616 line := strings.TrimSpace(p.Command + " " + strings.Join(p.Args, " "))
617 fmt.Printf("%-16s (stdio)%s %s\n", p.Name, auto, line)
618 } else {
619 fmt.Printf("%-16s (%s)%s %s\n", p.Name, typ, auto, p.URL)
620 }
621 listed++
622 }
623 if listed == 0 {
624 fmt.Println("no MCP servers configured")
625 }
626 return 0
627 }
628
629 func mcpGetCLI(args []string) int {
630 if len(args) == 0 {
631 fmt.Fprintln(os.Stderr, "usage: reasonix mcp get <name>")
632 return 2
633 }
634 name := args[0]
635 cfg, err := config.Load()
636 if err != nil {
637 fmt.Fprintln(os.Stderr, err)
638 return 1
639 }
640 for _, p := range cfg.Plugins {
641 if p.Name != name {
642 continue
643 }
644 printMCPEntry(p)
645 return 0
646 }
647 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
648 return 1
649 }
650
651 func printMCPEntry(p config.PluginEntry) {
652 typ := p.Type
653 if typ == "" {
654 typ = "stdio"
655 }
656 fmt.Printf("name: %s\n", p.Name)
657 fmt.Printf("type: %s\n", typ)
658 if typ == "stdio" {
659 fmt.Printf("command: %s\n", p.Command)
660 if len(p.Args) > 0 {
661 fmt.Printf("args: %s\n", strings.Join(p.Args, "\n "))
662 }
663 if len(p.Env) > 0 {
664 fmt.Println("env:")
665 for _, k := range sortedMapKeys(p.Env) {
666 fmt.Printf(" %s=%s\n", k, redactMCPConfigValue(k, p.Env[k]))
667 }
668 }
669 } else {
670 fmt.Printf("url: %s\n", redactMCPURL(p.URL))
671 if len(p.Headers) > 0 {
672 fmt.Println("headers:")
673 for _, k := range sortedMapKeys(p.Headers) {
674 fmt.Printf(" %s=%s\n", k, redactMCPConfigValue(k, p.Headers[k]))
675 }
676 }
677 }
678 if !p.ShouldAutoStart() {
679 fmt.Println("auto_start: false")
680 }
681 }
682
683 func sortedMapKeys(m map[string]string) []string {
684 keys := make([]string, 0, len(m))
685 for k := range m {
686 keys = append(keys, k)
687 }
688 sort.Strings(keys)
689 return keys
690 }
691
692 func redactMCPConfigValue(key, value string) string {
693 if looksSensitiveMCPKey(key) || looksSensitiveMCPValue(value) {
694 return "<redacted>"
695 }
696 return value
697 }
698
699 func looksSensitiveMCPKey(key string) bool {
700 lower := strings.ToLower(strings.TrimSpace(key))
701 for _, needle := range []string{"auth", "token", "secret", "credential", "api_key", "api-key", "apikey", "cookie"} {
702 if strings.Contains(lower, needle) {
703 return true
704 }
705 }
706 return false
707 }
708
709 func looksSensitiveMCPQueryKey(key string) bool {
710 return strings.EqualFold(strings.TrimSpace(key), "key") || looksSensitiveMCPKey(key)
711 }
712
713 func looksSensitiveMCPValue(value string) bool {
714 lower := strings.ToLower(value)
715 for _, needle := range []string{"access_token", "id_token", "refresh_token", "api_key", "api-key", "apikey", "bearer "} {
716 if strings.Contains(lower, needle) {
717 return true
718 }
719 }
720 return false
721 }
722
723 func redactMCPURL(raw string) string {
724 trimmed := strings.TrimSpace(raw)
725 if trimmed == "" {
726 return raw
727 }
728 u, err := url.Parse(trimmed)
729 if err != nil || u == nil {
730 if looksSensitiveMCPValue(raw) {
731 return "<redacted>"
732 }
733 return raw
734 }
735 q := u.Query()
736 changed := false
737 for key := range q {
738 if looksSensitiveMCPQueryKey(key) {
739 q.Set(key, "<redacted>")
740 changed = true
741 }
742 }
743 if !changed {
744 return raw
745 }
746 u.RawQuery = q.Encode()
747 return u.String()
748 }
749
750 func mcpAddCLI(args []string) int {
751 entry, err := parseMCPAdd(args)
752 if err != nil {
753 fmt.Fprintln(os.Stderr, err)
754 return 2
755 }
756 cfg, err := config.Load()
757 if err != nil {
758 fmt.Fprintln(os.Stderr, err)
759 return 1
760 }
761 for _, configured := range cfg.Plugins {
762 if configured.Name == entry.Name {
763 fmt.Fprintf(os.Stderr, "MCP server %q is already configured; remove it first or choose another name\n", entry.Name)
764 return 1
765 }
766 }
767 result, probeErr := mcpProbeForInstall(entry)
768 if probeErr != nil && result.State != "action_required" {
769 fmt.Fprintf(os.Stderr, "MCP server %q was not added: %s\n", entry.Name, result.Message)
770 return 1
771 }
772 if err := persistCLIInstalledMCP(mcpCLIWorkspaceRoot(), entry); err != nil {
773 fmt.Fprintln(os.Stderr, err)
774 return 1
775 }
776 if result.State == "action_required" {
777 fmt.Printf("added MCP server %q — authentication required; finish authentication and retry\n", entry.Name)
778 return 0
779 }
780 fmt.Printf("added MCP server %q — ready with %d tools\n", entry.Name, result.ToolCount)
781 return 0
782 }
783
784 var mcpProbeForInstall = probeMCPReadiness
785
786 func probeMCPReadiness(entry config.PluginEntry) (plugin.MCPInstallResult, error) {
787 entry.Source = config.MCPSourceUserConfig
788 workspace, err := os.Getwd()
789 if err != nil {
790 workspace = ""
791 }
792 specs := boot.PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, workspace, boot.PluginSpecOptions{
793 DefaultCallTimeout: 30 * time.Second,
794 ConfigSource: string(config.MCPSourceUserConfig),
795 StateHome: config.ReasonixHomeDir(),
796 Network: true,
797 })
798 if len(specs) != 1 {
799 err := fmt.Errorf("could not build MCP launch specification")
800 return plugin.InstallResultForError(entry.Name, err), err
801 }
802 host := plugin.NewHost()
803 defer host.Close()
804 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
805 defer cancel()
806 return host.InstallAndConnect(ctx, specs[0])
807 }
808
809 func persistCLIInstalledMCP(workspace string, entry config.PluginEntry) error {
810 entry.Source = config.MCPSourceUserConfig
811 _, err := config.InstallUserPluginForRoot(workspace, entry, entry.ShouldAutoStart())
812 return err
813 }
814
815 func mcpRemoveCLI(args []string) int {
816 if len(args) == 0 {
817 fmt.Fprintln(os.Stderr, "usage: reasonix mcp remove <name>")
818 return 2
819 }
820 name := args[0]
821 workspace := mcpCLIWorkspaceRoot()
822 removed, ok, _, err := config.RemovePluginFromEffectiveSourceForRoot(workspace, name)
823 if err != nil {
824 fmt.Fprintln(os.Stderr, err)
825 return 1
826 }
827 if !ok {
828 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
829 return 1
830 }
831 // Uninstall clears activation overrides; schema/auth cleanup is handled by
832 // the live session path when present.
833 _ = config.DefaultMCPActivationStore().ClearServer(removed, workspace)
834 fmt.Printf("removed MCP server %q\n", name)
835 return 0
836 }
837
838 func mcpCLIWorkspaceRoot() string {
839 if cwd, err := os.Getwd(); err == nil && strings.TrimSpace(cwd) != "" {
840 return cwd
841 }
842 return "."
843 }
844
845 func mcpUsage() {
846 fmt.Println(`Manage MCP servers (global installs use config.toml; project entries stay in project config).
847
848 Usage:
849 reasonix mcp list
850 reasonix mcp get <name>
851 reasonix mcp install <registry-name> [--as <name>]
852 reasonix mcp add -- <command> [args...] stdio argv (no shell)
853 reasonix mcp add <name> -- <command> [args...]
854 reasonix mcp add <name> <command> [args...] legacy stdio form
855 reasonix mcp add https://example.com/mcp remote HTTP
856 reasonix mcp add <name> --http <url> [--header K=V]
857 reasonix mcp add <name> --sse <url>
858 reasonix mcp enable <name>
859 reasonix mcp disable <name>
860 reasonix mcp retry <name>
861 reasonix mcp update <name>
862 reasonix mcp browse [query] [--limit N] [--json]
863 reasonix mcp import
864 reasonix mcp remove <name>
865
866 Flags for add:
867 --http <url> | --sse <url> remote transport (omit for a stdio command)
868 --env K=V set an environment variable (repeatable, stdio)
869 --header K=V set an HTTP header (repeatable, remote)
870
871 Examples:
872 reasonix mcp add fs npx -y @modelcontextprotocol/server-filesystem .
873 reasonix mcp add stripe --http https://mcp.stripe.com --header "Authorization=Bearer $STRIPE_KEY"
874
875 CLI config changes take effect on the next session. Inside a running chat, use
876 /mcp add to save and connect a server immediately. Installing a server is also
877 its authorization; there is no separate trust step.
878
879 Servers declared by project reasonix.toml or .mcp.json are trusted configuration
880 and need no separate launch confirmation. Project entries override same-name
881 global entries; within a project, reasonix.toml overrides .mcp.json. Writer or
882 destructive annotations never trigger per-call approval. Explicit deny rules
883 still win; Plan Mode and strict read-only subagents may filter which tools are
884 available.`)
885 }
886
886 lines GO