返回 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 if config.RepositoryDeclared(entry) {
541 fmt.Printf("approved for %s: %s (a later change to this declaration needs approval again)\n", workspace, config.MCPLaunchLine(entry))
542 }
543 } else {
544 fmt.Printf("disabled MCP server %q — tools removed from the catalog; authorization retained\n", name)
545 }
546 return 0
547 }
548
549 func mcpRetryCLI(args []string) int {
550 if len(args) == 0 {
551 fmt.Fprintln(os.Stderr, "usage: reasonix mcp retry <name>")
552 return 2
553 }
554 // Standalone CLI cannot talk to a live Host; enabling is the durable
555 // equivalent of "retry next session". In-chat /mcp retry remains live.
556 return mcpEnableCLI(args, true)
557 }
558
559 func mcpUpdateCLI(args []string) int {
560 if len(args) == 0 {
561 fmt.Fprintln(os.Stderr, "usage: reasonix mcp update <name>")
562 return 2
563 }
564 name := strings.TrimSpace(args[0])
565 cfg, err := config.Load()
566 if err != nil {
567 fmt.Fprintln(os.Stderr, err)
568 return 1
569 }
570 var entry config.PluginEntry
571 found := false
572 for _, configured := range cfg.Plugins {
573 if configured.Name == name {
574 entry, found = configured, true
575 break
576 }
577 }
578 if !found {
579 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
580 return 1
581 }
582 result, probeErr := mcpProbeForInstall(entry)
583 if probeErr != nil {
584 fmt.Fprintf(os.Stderr, "MCP update for %q was not applied: %s\n", name, result.Message)
585 return 1
586 }
587 fmt.Printf("updated MCP server %q — candidate handshake passed with %d tools; cached schema switched atomically\n", name, result.ToolCount)
588 return 0
589 }
590
591 func mcpImportCLI() int {
592 total, added, updated, err := config.ImportCCSwitchMCP()
593 if err != nil {
594 fmt.Fprintln(os.Stderr, err)
595 return 1
596 }
597 fmt.Printf("imported %d MCP servers from cc-switch (%d added, %d updated) — servers load on the next session\n", total, added, updated)
598 return 0
599 }
600
601 func mcpGetCLI(args []string) int {
602 if len(args) == 0 {
603 fmt.Fprintln(os.Stderr, "usage: reasonix mcp get <name>")
604 return 2
605 }
606 name := args[0]
607 cfg, err := config.LoadForRoot(mcpCLIWorkspaceRoot())
608 if err != nil {
609 fmt.Fprintln(os.Stderr, err)
610 return 1
611 }
612 for _, p := range cfg.Plugins {
613 if p.Name != name {
614 continue
615 }
616 printMCPEntry(p)
617 return 0
618 }
619 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
620 return 1
621 }
622
623 func printMCPEntry(p config.PluginEntry) {
624 typ := p.Type
625 if typ == "" {
626 typ = "stdio"
627 }
628 fmt.Printf("name: %s\n", p.Name)
629 fmt.Printf("type: %s\n", typ)
630 if typ == "stdio" {
631 fmt.Printf("command: %s\n", p.Command)
632 if len(p.Args) > 0 {
633 fmt.Printf("args: %s\n", strings.Join(p.Args, "\n "))
634 }
635 if len(p.Env) > 0 {
636 fmt.Println("env:")
637 for _, k := range sortedMapKeys(p.Env) {
638 fmt.Printf(" %s=%s\n", k, config.RedactMCPConfigValue(k, p.Env[k]))
639 }
640 }
641 } else {
642 fmt.Printf("url: %s\n", config.RedactMCPURL(p.URL))
643 if len(p.Headers) > 0 {
644 fmt.Println("headers:")
645 for _, k := range sortedMapKeys(p.Headers) {
646 fmt.Printf(" %s=%s\n", k, config.RedactMCPConfigValue(k, p.Headers[k]))
647 }
648 }
649 }
650 if !p.ShouldAutoStart() {
651 fmt.Println("auto_start: false")
652 }
653 }
654
655 func sortedMapKeys(m map[string]string) []string {
656 keys := make([]string, 0, len(m))
657 for k := range m {
658 keys = append(keys, k)
659 }
660 sort.Strings(keys)
661 return keys
662 }
663
664 func mcpAddCLI(args []string) int {
665 entry, err := parseMCPAdd(args)
666 if err != nil {
667 fmt.Fprintln(os.Stderr, err)
668 return 2
669 }
670 cfg, err := config.Load()
671 if err != nil {
672 fmt.Fprintln(os.Stderr, err)
673 return 1
674 }
675 for _, configured := range cfg.Plugins {
676 if configured.Name == entry.Name {
677 fmt.Fprintf(os.Stderr, "MCP server %q is already configured; remove it first or choose another name\n", entry.Name)
678 return 1
679 }
680 }
681 result, probeErr := mcpProbeForInstall(entry)
682 if probeErr != nil && result.State != "action_required" {
683 fmt.Fprintf(os.Stderr, "MCP server %q was not added: %s\n", entry.Name, result.Message)
684 return 1
685 }
686 if err := persistCLIInstalledMCP(mcpCLIWorkspaceRoot(), entry); err != nil {
687 fmt.Fprintln(os.Stderr, err)
688 return 1
689 }
690 if result.State == "action_required" {
691 fmt.Printf("added MCP server %q — authentication required; finish authentication and retry\n", entry.Name)
692 return 0
693 }
694 fmt.Printf("added MCP server %q — ready with %d tools\n", entry.Name, result.ToolCount)
695 return 0
696 }
697
698 func persistCLIInstalledMCP(workspace string, entry config.PluginEntry) error {
699 entry.Source = config.MCPSourceUserConfig
700 _, err := config.InstallUserPluginForRoot(workspace, entry, entry.ShouldAutoStart())
701 return err
702 }
703
704 func mcpRemoveCLI(args []string) int {
705 if len(args) == 0 {
706 fmt.Fprintln(os.Stderr, "usage: reasonix mcp remove <name>")
707 return 2
708 }
709 name := args[0]
710 workspace := mcpCLIWorkspaceRoot()
711 removed, ok, _, err := config.RemovePluginFromEffectiveSourceForRoot(workspace, name)
712 if err != nil {
713 fmt.Fprintln(os.Stderr, err)
714 return 1
715 }
716 if !ok {
717 fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name)
718 return 1
719 }
720 // Uninstall clears activation overrides and Reasonix-owned OAuth state. A
721 // same-resource lower-priority declaration keeps owning the shared state.
722 _ = config.DefaultMCPActivationStore().ClearServer(removed, workspace)
723 if err := reconcileRemovedMCPOAuth(workspace, name); err != nil {
724 fmt.Fprintf(os.Stderr, "removed MCP server %q, but failed to reconcile OAuth state: %v\n", name, err)
725 return 1
726 }
727 fmt.Printf("removed MCP server %q\n", name)
728 return 0
729 }
730
731 func mcpUsage() {
732 fmt.Println(`Manage MCP servers (global installs use config.toml; project entries stay in project config).
733
734 Usage:
735 reasonix mcp list
736 reasonix mcp get <name>
737 reasonix mcp install <registry-name> [--as <name>]
738 reasonix mcp add -- <command> [args...] stdio argv (no shell)
739 reasonix mcp add <name> -- <command> [args...]
740 reasonix mcp add <name> <command> [args...] legacy stdio form
741 reasonix mcp add https://example.com/mcp remote HTTP
742 reasonix mcp add <name> --http <url> [--header K=V]
743 reasonix mcp add <name> --sse <url>
744 reasonix mcp enable <name>
745 reasonix mcp disable <name>
746 reasonix mcp retry <name>
747 reasonix mcp auth <name> remote OAuth (opens browser)
748 reasonix mcp update <name>
749 reasonix mcp browse [query] [--limit N] [--json]
750 reasonix mcp import
751 reasonix mcp remove <name>
752
753 Flags for add:
754 --http <url> | --sse <url> remote transport (omit for a stdio command)
755 --env K=V set an environment variable (repeatable, stdio)
756 --header K=V set an HTTP header (repeatable, remote)
757
758 Examples:
759 reasonix mcp add fs npx -y @modelcontextprotocol/server-filesystem .
760 reasonix mcp add stripe --http https://mcp.stripe.com --header "Authorization=Bearer $STRIPE_KEY"
761
762 CLI config changes take effect on the next session. Inside a running chat, use
763 /mcp add to save and connect a server immediately. Installing a server is also
764 its launch authorization; there is no separate trust step. Remote OAuth, when
765 requested by the server, is completed with reasonix mcp auth <name> and stored
766 in Reasonix-private MCP state.
767
768 Servers declared by project reasonix.toml or .mcp.json are trusted configuration
769 and need no separate launch confirmation. Project entries override same-name
770 global entries; within a project, reasonix.toml overrides .mcp.json. Writer or
771 destructive annotations never trigger per-call approval. Explicit deny rules
772 still win; Plan Mode and strict read-only subagents may filter which tools are
773 available.`)
774 }
775
775 lines GO