package tui import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "cmp" "errors" "fmt" "net/url" "path" "regexp" "slices" "strings" tea "charm.land/bubbletea/v2" ) // mcpForm adds or edits one MCP server on a single screen. Nothing is written // until the whole form is saved, so a server that needs credentials gets them // before its first connection attempt. type mcpForm struct { form // Editing names the server being edited; empty for a new server. Editing string Transport string // "http" or "stdio" // Stored names the secrets the daemon already holds for this server. Stored daemon.MCPSecretNames // Base keeps fields the form does not edit (enabled, tools, timeouts). Base config.MCPServer // NameTouched stops the name following the URL or command once typed. NameTouched bool } const ( fieldTransport = "transport" fieldURL = "url" fieldCommand = "command" fieldName = "name" fieldToken = "token" fieldHeader = "header" fieldValue = "value" fieldEnv = "env" ) // mcpFieldLabels renames the fields whose key is not the words a person // reads; the rest label themselves. var mcpFieldLabels = map[string]string{ fieldToken: "bearer token", fieldValue: "header value", } func newMCPForm(editing string, server config.MCPServer, stored daemon.MCPSecretNames) *mcpForm { transport := "http" if server.Type == "stdio" { transport = "stdio" } keys := []string{fieldURL, fieldCommand, fieldName, fieldToken, fieldHeader, fieldValue, fieldEnv} f := &mcpForm{Editing: editing, Transport: transport, Base: server, Stored: stored, form: newForm(keys, fieldToken, fieldValue, fieldEnv)} f.Inputs[fieldURL].SetValue(server.URL) f.Inputs[fieldCommand].SetValue(joinCommand(server.Command, server.Args)) f.Inputs[fieldName].SetValue(editing) f.NameTouched = editing != "" if stored.BearerToken { f.Inputs[fieldToken].Placeholder = "stored · leave blank to keep it, or enter - to remove it" } if len(stored.Headers) > 0 { f.Inputs[fieldHeader].Placeholder = strings.Join(stored.Headers, ", ") + " stored · add or replace one" } f.Inputs[fieldEnv].Placeholder = "KEY=value KEY2=value (optional)" if len(stored.Env) > 0 { f.Inputs[fieldEnv].Placeholder = strings.Join(stored.Env, ", ") + " stored · KEY=value adds or replaces" } f.Focus = 1 // the address: transport already defaults to http f.focus(f.current()) return f } // fields lists the rows for the current transport, in order. func (f *mcpForm) fields() []string { if f.Transport == "stdio" { return []string{fieldTransport, fieldCommand, fieldName, fieldEnv} } return []string{fieldTransport, fieldURL, fieldName, fieldToken, fieldHeader, fieldValue} } func (f *mcpForm) current() string { fields := f.fields() return fields[min(f.Focus, len(fields)-1)] } // update handles one key or paste; submit reports that the form should be saved. func (f *mcpForm) update(msg tea.Msg) (submit bool, cmd tea.Cmd) { if submit, handled := f.key(msg, f.fields(), 0); handled { return submit, nil } if f.current() == fieldTransport { if key, ok := msg.(tea.KeyPressMsg); ok && f.Editing == "" { switch key.String() { case "left", "right", "space": if f.Transport == "http" { f.Transport = "stdio" } else { f.Transport = "http" } f.suggestName() } } return false, nil } if f.current() == fieldName && f.Editing != "" { return false, nil // renaming would orphan stored credentials } input := f.Inputs[f.current()] before := input.Value() next, cmd := input.Update(msg) *input = next if input.Value() != before { switch f.current() { case fieldName: f.NameTouched = true case fieldURL, fieldCommand: f.suggestName() } } return false, cmd } func (f *mcpForm) suggestName() { if f.NameTouched { return } name := "" if f.Transport == "http" { name = nameFromURL(f.Inputs[fieldURL].Value()) } else if argv, err := splitCommand(f.Inputs[fieldCommand].Value()); err == nil { name = nameFromCommand(argv) } f.Inputs[fieldName].SetValue(name) } // mcpSubmission is a validated form: the server entry and the change to its // saved credentials. type mcpSubmission struct { Secrets daemon.MCPSecretsPatch Name string Server config.MCPServer } func (f *mcpForm) submission(existing []capabilityItem) (mcpSubmission, error) { name := f.value(fieldName) if !mcpName.MatchString(name) { return mcpSubmission{}, errors.New("name: 1–64 letters, digits, _ or -") } if f.Editing == "" && slices.ContainsFunc(existing, func(item capabilityItem) bool { return item.ID == name }) { return mcpSubmission{}, fmt.Errorf("a server called %s already exists; choose another name", name) } server := f.Base server.Type = f.Transport secrets := daemon.MCPSecretsPatch{} if f.Transport == "http" { raw := f.value(fieldURL) parsed, err := url.Parse(raw) if raw == "" || err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return mcpSubmission{}, errors.New("enter a server URL starting with http:// or https://") } server.URL, server.Command, server.Args, server.CWD = raw, "", nil, "" if token := f.value(fieldToken); token == "-" { secrets.RemoveBearerToken = true } else if token != "" { secrets.BearerToken = &token } header, value := f.value(fieldHeader), f.value(fieldValue) switch { case header == "" && value == "": case !mcpHeaderName.MatchString(header): return mcpSubmission{}, errors.New("enter a valid HTTP header name") case value == "-": secrets.Headers = map[string]*string{header: nil} case value == "": return mcpSubmission{}, fmt.Errorf("enter a value for %s, or - to remove it", header) default: secrets.Headers = map[string]*string{header: &value} } secrets.ClearEnv = true } else { argv, err := splitCommand(f.Inputs[fieldCommand].Value()) if err != nil || len(argv) == 0 { return mcpSubmission{}, errors.New("enter the command that starts the server") } server.Command, server.Args, server.URL = argv[0], argv[1:], "" entries, err := splitCommand(f.Inputs[fieldEnv].Value()) if err != nil { return mcpSubmission{}, fmt.Errorf("check environment variables: %w", err) } var env map[string]*string for _, entry := range entries { key, value, ok := strings.Cut(entry, "=") if !ok || !mcpEnvName.MatchString(key) { return mcpSubmission{}, errors.New("enter environment variables as KEY=value pairs separated by spaces") } if env == nil { env = map[string]*string{} } if value == "-" { env[key] = nil } else { env[key] = &value } } if env != nil { secrets.Env = env } secrets.RemoveBearerToken, secrets.ClearHeaders = true, true } return mcpSubmission{Name: name, Server: server, Secrets: secrets}, nil } // view is the form's rows; footer is its keys, shown in the screen's footer. func (f *mcpForm) view(width int) []string { f.fit(width - 16) title := "add MCP server" if f.Editing != "" { title = "edit " + f.Editing } rows := []string{DefaultStyles.Bold.Render(title)} for i, key := range f.fields() { value := f.Transport if key != fieldTransport { value = f.Inputs[key].View() } else if f.Editing == "" { value = " http ‹stdio›" if f.Transport == "http" { value = "‹http› stdio " } } label := cmp.Or(mcpFieldLabels[key], key) rows = append(rows, formRow(i == f.Focus, label, 13, value, width)) } return rows } // footer explains the focused field and lists the form's keys. func (f *mcpForm) footer(width int) string { var own []hint note := "" switch f.current() { case fieldTransport: if f.Editing == "" { own = []hint{{"←→", "http or stdio"}} } case fieldToken: note = "optional · sent as an Authorization: Bearer header" case fieldHeader, fieldValue: note = "optional custom header, stored privately" case fieldEnv: note = "optional, stored privately" } return formFooter(note, width, own...) } var nameUnsafe = regexp.MustCompile(`[^A-Za-z0-9_-]+`) // nameFromURL suggests a server name from its host: mcp.linear.app → linear. func nameFromURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Hostname() == "" { return "" } labels := strings.Split(parsed.Hostname(), ".") for len(labels) > 1 && (labels[0] == "mcp" || labels[0] == "api" || labels[0] == "www") { labels = labels[1:] } if len(labels) == 4 && strings.Trim(parsed.Hostname(), "0123456789.") == "" { return cleanName("mcp-" + strings.ReplaceAll(parsed.Hostname(), ".", "-")) } return cleanName(labels[0]) } // nameFromCommand suggests a name from the package or program a command // runs: npx -y @modelcontextprotocol/server-filesystem → filesystem. func nameFromCommand(argv []string) string { if len(argv) == 0 { return "" } target := path.Base(argv[0]) switch target { case "npx", "uvx", "bunx", "pnpx", "pipx", "node", "python", "python3", "uv", "docker": for _, arg := range argv[1:] { if !strings.HasPrefix(arg, "-") && arg != "run" && arg != "tool" { target = arg break } } } target = path.Base(target) if at := strings.LastIndex(target, "@"); at > 0 { target = target[:at] } for _, affix := range []string{"server-", "mcp-server-", "mcp-"} { target = strings.TrimPrefix(target, affix) } for _, affix := range []string{"-mcp-server", "-mcp", "-server"} { target = strings.TrimSuffix(target, affix) } return cleanName(target) } func cleanName(name string) string { name = strings.Trim(nameUnsafe.ReplaceAllString(strings.ToLower(name), "-"), "-_") if len(name) > 64 { name = name[:64] } return name } // splitCommand splits a command line on spaces, honouring single and double // quotes and backslash escapes, without invoking a shell. func splitCommand(line string) ([]string, error) { var args []string var current strings.Builder inArg, quote, escaped := false, rune(0), false for _, r := range line { switch { case escaped: current.WriteRune(r) escaped = false case r == '\\' && quote != '\'': escaped, inArg = true, true case quote != 0: if r == quote { quote = 0 } else { current.WriteRune(r) } case r == '\'' || r == '"': quote, inArg = r, true case r == ' ' || r == '\t': if inArg { args = append(args, current.String()) current.Reset() inArg = false } default: current.WriteRune(r) inArg = true } } if quote != 0 || escaped { return nil, errors.New("unterminated quote") } if inArg { args = append(args, current.String()) } return args, nil } func joinCommand(command string, args []string) string { if command == "" { return "" } parts := []string{quoteArg(command)} for _, arg := range args { parts = append(parts, quoteArg(arg)) } return strings.Join(parts, " ") } func quoteArg(arg string) string { if arg != "" && !strings.ContainsAny(arg, " \t'\"\\") { return arg } return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" }