package cli import ( "fmt" "net/url" "os" "sort" "strings" "reasonix/internal/config" ) // mcp.go holds the MCP server-management surface shared by the `reasonix mcp` // subcommand (config-only; takes effect next session) and the in-chat `/mcp add` // / `/mcp remove` slash commands (which hot-connect via the controller). Both // parse arguments through parseMCPAdd so the grammar is identical everywhere. // parseMCPAdd turns the arguments after "add" into a config.PluginEntry. Grammar: // // [--http URL | --sse URL] [--env K=V]... [--header K=V]... [command [args...]] // // A --http/--sse URL makes it a remote server; otherwise the first non-flag token // (after the name and any --env/--header flags) begins the stdio command, and the // rest are its args verbatim — so the command keeps its own -flags (e.g. `npx -y // pkg`). Flag values accept both "--http URL" and "--http=URL" forms. func parseMCPAdd(args []string) (config.PluginEntry, error) { var e config.PluginEntry if len(args) == 0 { return e, fmt.Errorf("mcp add: missing server name") } e.Name = strings.TrimSpace(args[0]) if e.Name == "" || strings.HasPrefix(e.Name, "-") { return e, fmt.Errorf("mcp add: first argument must be the server name, got %q", args[0]) } rest := args[1:] i := 0 // next consumes the following token as a flag's value (for the "--flag value" // form), reporting false when none remains. next := func(flag string) (string, error) { if i+1 >= len(rest) { return "", fmt.Errorf("mcp add: %s needs a value", flag) } i++ return rest[i], nil } setEnv := func(dst *map[string]string, flag, pair string) error { k, v, ok := strings.Cut(pair, "=") if !ok || strings.TrimSpace(k) == "" { return fmt.Errorf("mcp add: %s expects KEY=VALUE, got %q", flag, pair) } if *dst == nil { *dst = map[string]string{} } (*dst)[k] = v return nil } for ; i < len(rest); i++ { a := rest[i] key, inline, hasInline := strings.Cut(a, "=") switch { case !strings.HasPrefix(a, "-"): // The stdio command and its remaining args, verbatim. e.Command = a e.Args = append([]string(nil), rest[i+1:]...) i = len(rest) case key == "--http" || key == "--streamable-http": v := inline if !hasInline { var err error if v, err = next(key); err != nil { return e, err } } e.Type, e.URL = "http", v case key == "--sse": v := inline if !hasInline { var err error if v, err = next(key); err != nil { return e, err } } e.Type, e.URL = "sse", v case key == "--env" || key == "--header": pair := inline if !hasInline { var err error if pair, err = next(key); err != nil { return e, err } } dst := &e.Env if key == "--header" { dst = &e.Headers } if err := setEnv(dst, key, pair); err != nil { return e, err } default: return e, fmt.Errorf("mcp add: unknown flag %q", a) } } switch { case e.URL != "" && e.Command != "": return e, fmt.Errorf("mcp add: specify a command OR a --http/--sse URL, not both") case e.URL == "" && e.Command == "": return e, fmt.Errorf("mcp add: need a command (stdio) or a --http/--sse URL") } return e, nil } // tokenizeArgs splits a slash-command line into arguments, honouring "double" and // 'single' quotes so values with spaces (e.g. --header "Authorization=Bearer x") // survive. An unterminated quote takes the rest of the line as one token. func tokenizeArgs(s string) []string { var out []string var cur strings.Builder inWord := false var quote rune for _, r := range s { switch { case quote != 0: if r == quote { quote = 0 } else { cur.WriteRune(r) } inWord = true case r == '"' || r == '\'': quote = r inWord = true case r == ' ' || r == '\t': if inWord { out = append(out, cur.String()) cur.Reset() inWord = false } default: cur.WriteRune(r) inWord = true } } if inWord { out = append(out, cur.String()) } return out } // mcpCommand implements `reasonix mcp `. It edits config only // (validate → UpsertPlugin/RemovePlugin → Save); the server connects on the next // session start. For a live connect inside an open chat, use `/mcp add`. func mcpCommand(args []string) int { if len(args) == 0 { mcpUsage() return 2 } switch args[0] { case "list", "ls": return mcpList() case "add": return mcpAddCLI(args[1:]) case "get": return mcpGetCLI(args[1:]) case "remove", "rm": return mcpRemoveCLI(args[1:]) case "import": return mcpImportCLI() case "help", "-h", "--help": mcpUsage() return 0 default: fmt.Fprintf(os.Stderr, "unknown mcp subcommand %q\n\n", args[0]) mcpUsage() return 2 } } func mcpImportCLI() int { total, added, updated, err := config.ImportCCSwitchMCP() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Printf("imported %d MCP servers from cc-switch (%d added, %d updated) — servers load on the next session\n", total, added, updated) return 0 } func mcpList() int { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } listed := 0 for _, p := range cfg.Plugins { typ := p.Type if typ == "" { typ = "stdio" } auto := "" if !p.ShouldAutoStart() { auto = " [auto_start=false]" } if typ == "stdio" { line := strings.TrimSpace(p.Command + " " + strings.Join(p.Args, " ")) fmt.Printf("%-16s (stdio)%s %s\n", p.Name, auto, line) } else { fmt.Printf("%-16s (%s)%s %s\n", p.Name, typ, auto, p.URL) } listed++ } if listed == 0 { fmt.Println("no MCP servers configured") } return 0 } func mcpGetCLI(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: reasonix mcp get ") return 2 } name := args[0] cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } for _, p := range cfg.Plugins { if p.Name != name { continue } printMCPEntry(p) return 0 } fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name) return 1 } func printMCPEntry(p config.PluginEntry) { typ := p.Type if typ == "" { typ = "stdio" } fmt.Printf("name: %s\n", p.Name) fmt.Printf("type: %s\n", typ) if typ == "stdio" { fmt.Printf("command: %s\n", p.Command) if len(p.Args) > 0 { fmt.Printf("args: %s\n", strings.Join(p.Args, "\n ")) } if len(p.Env) > 0 { fmt.Println("env:") for _, k := range sortedMapKeys(p.Env) { fmt.Printf(" %s=%s\n", k, redactMCPConfigValue(k, p.Env[k])) } } } else { fmt.Printf("url: %s\n", redactMCPURL(p.URL)) if len(p.Headers) > 0 { fmt.Println("headers:") for _, k := range sortedMapKeys(p.Headers) { fmt.Printf(" %s=%s\n", k, redactMCPConfigValue(k, p.Headers[k])) } } } if !p.ShouldAutoStart() { fmt.Println("auto_start: false") } } func sortedMapKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } func redactMCPConfigValue(key, value string) string { if looksSensitiveMCPKey(key) || looksSensitiveMCPValue(value) { return "" } return value } func looksSensitiveMCPKey(key string) bool { lower := strings.ToLower(strings.TrimSpace(key)) for _, needle := range []string{"auth", "token", "secret", "credential", "api_key", "api-key", "apikey", "cookie"} { if strings.Contains(lower, needle) { return true } } return false } func looksSensitiveMCPQueryKey(key string) bool { return strings.EqualFold(strings.TrimSpace(key), "key") || looksSensitiveMCPKey(key) } func looksSensitiveMCPValue(value string) bool { lower := strings.ToLower(value) for _, needle := range []string{"access_token", "id_token", "refresh_token", "api_key", "api-key", "apikey", "bearer "} { if strings.Contains(lower, needle) { return true } } return false } func redactMCPURL(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return raw } u, err := url.Parse(trimmed) if err != nil || u == nil { if looksSensitiveMCPValue(raw) { return "" } return raw } q := u.Query() changed := false for key := range q { if looksSensitiveMCPQueryKey(key) { q.Set(key, "") changed = true } } if !changed { return raw } u.RawQuery = q.Encode() return u.String() } func mcpAddCLI(args []string) int { entry, err := parseMCPAdd(args) if err != nil { fmt.Fprintln(os.Stderr, err) return 2 } cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := cfg.UpsertPlugin(entry); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := cfg.Save(); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Printf("added MCP server %q — loads on the next session (or run `/mcp add` inside chat to connect it live now)\n", entry.Name) return 0 } func mcpRemoveCLI(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: reasonix mcp remove ") return 2 } name := args[0] cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if !cfg.RemovePlugin(name) { fmt.Fprintf(os.Stderr, "no MCP server named %q in config\n", name) return 1 } if err := cfg.Save(); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Printf("removed MCP server %q\n", name) return 0 } func mcpUsage() { fmt.Println(`Manage MCP servers (persisted to reasonix.toml). Usage: reasonix mcp list reasonix mcp get reasonix mcp add [args...] stdio server reasonix mcp add --http [--header K=V] remote (Streamable HTTP) reasonix mcp add --sse remote (legacy SSE) reasonix mcp import import MCP servers from cc-switch reasonix mcp remove Flags for add: --http | --sse remote transport (omit for a stdio command) --env K=V set an environment variable (repeatable, stdio) --header K=V set an HTTP header (repeatable, remote) Examples: reasonix mcp add fs npx -y @modelcontextprotocol/server-filesystem . reasonix mcp add stripe --http https://mcp.stripe.com --header "Authorization=Bearer $STRIPE_KEY" Changes take effect on the next session; inside a running chat, use /mcp add to connect a server live. MCP tools that report readOnlyHint are confirmed on first plan-mode use. Choose "always allow" in the approval prompt to remember that read-only trust; advanced users may pre-seed trusted_read_only_tools in config. Auto/YOLO tool approval does not answer this trust prompt.`) }