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