okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
96704f7
main
1package tui 2 3import ( 4"albedo/cli/internal/config" 5"albedo/cli/internal/daemon" 6"cmp" 7"errors" 8"fmt" 9"net/url" 10"path" 11"regexp" 12"slices" 13"strings" 14 15 tea"charm.land/bubbletea/v2" 16) 17 18// mcpForm adds or edits one MCP server on a single screen. Nothing is written 19// until the whole form is saved, so a server that needs credentials gets them 20// before its first connection attempt. 21type mcpForm struct { 22form 23// Editing names the server being edited; empty for a new server. 24Editing string 25Transport string // "http" or "stdio" 26// Stored names the secrets the daemon already holds for this server. 27Stored daemon.MCPSecretNames 28// Base keeps fields the form does not edit (enabled, tools, timeouts). 29Base config.MCPServer 30// NameTouched stops the name following the URL or command once typed. 31NameTouched bool 32} 33 34const ( 35fieldTransport = "transport" 36fieldURL = "url" 37fieldCommand = "command" 38fieldName = "name" 39fieldToken = "token" 40fieldHeader = "header" 41fieldValue = "value" 42fieldEnv = "env" 43) 44 45// mcpFieldLabels renames the fields whose key is not the words a person 46// reads; the rest label themselves. 47var mcpFieldLabels = map [string ]string { 48fieldToken :"bearer token" , 49fieldValue :"header value" , 50} 51 52func newMCPForm (editing string ,server config.MCPServer ,stored daemon.MCPSecretNames )* mcpForm { 53transport := "http" 54if server .Type == "stdio" { 55transport = "stdio" 56 } 57keys := []string {fieldURL ,fieldCommand ,fieldName ,fieldToken ,fieldHeader ,fieldValue ,fieldEnv } 58f := & mcpForm {Editing :editing ,Transport :transport ,Base :server ,Stored :stored ,form :newForm (keys ,fieldToken ,fieldValue ,fieldEnv )} 59f .Inputs [fieldURL ].SetValue (server .URL ) 60f .Inputs [fieldCommand ].SetValue (joinCommand (server .Command ,server .Args )) 61f .Inputs [fieldName ].SetValue (editing ) 62f .NameTouched = editing != "" 63if stored .BearerToken { 64f .Inputs [fieldToken ].Placeholder = "stored · leave blank to keep it, or enter - to remove it" 65 } 66if len (stored .Headers )> 0 { 67f .Inputs [fieldHeader ].Placeholder = strings .Join (stored .Headers ,", " )+ " stored · add or replace one" 68 } 69f .Inputs [fieldEnv ].Placeholder = "KEY=value KEY2=value (optional)" 70if len (stored .Env )> 0 { 71f .Inputs [fieldEnv ].Placeholder = strings .Join (stored .Env ,", " )+ " stored · KEY=value adds or replaces" 72 } 73f .Focus = 1 // the address: transport already defaults to http 74f .focus (f .current ()) 75return f 76} 77 78// fields lists the rows for the current transport, in order. 79func (f * mcpForm )fields () []string { 80if f .Transport == "stdio" { 81return []string {fieldTransport ,fieldCommand ,fieldName ,fieldEnv } 82 } 83return []string {fieldTransport ,fieldURL ,fieldName ,fieldToken ,fieldHeader ,fieldValue } 84} 85 86func (f * mcpForm )current ()string { 87fields := f .fields () 88return fields [min (f .Focus ,len (fields )- 1 )] 89} 90 91// update handles one key or paste; submit reports that the form should be saved. 92func (f * mcpForm )update (msg tea.Msg ) (submit bool ,cmd tea.Cmd ) { 93if submit ,handled := f .key (msg ,f .fields (),0 );handled { 94return submit ,nil 95 } 96if f .current ()== fieldTransport { 97if key ,ok := msg .(tea.KeyPressMsg );ok && f .Editing == "" { 98switch key .String () { 99case "left" ,"right" ,"space" : 100if f .Transport == "http" { 101f .Transport = "stdio" 102 }else { 103f .Transport = "http" 104 } 105f .suggestName () 106 } 107 } 108return false ,nil 109 } 110if f .current ()== fieldName && f .Editing != "" { 111return false ,nil // renaming would orphan stored credentials 112 } 113input := f .Inputs [f .current ()] 114before := input .Value () 115next ,cmd := input .Update (msg ) 116* input = next 117if input .Value ()!= before { 118switch f .current () { 119case fieldName : 120f .NameTouched = true 121case fieldURL ,fieldCommand : 122f .suggestName () 123 } 124 } 125return false ,cmd 126} 127 128func (f * mcpForm )suggestName () { 129if f .NameTouched { 130return 131 } 132name := "" 133if f .Transport == "http" { 134name = nameFromURL (f .Inputs [fieldURL ].Value ()) 135 }else if argv ,err := splitCommand (f .Inputs [fieldCommand ].Value ());err == nil { 136name = nameFromCommand (argv ) 137 } 138f .Inputs [fieldName ].SetValue (name ) 139} 140 141// mcpSubmission is a validated form: the server entry and the change to its 142// saved credentials. 143type mcpSubmission struct { 144Secrets daemon.MCPSecretsPatch 145Name string 146Server config.MCPServer 147} 148 149func (f * mcpForm )submission (existing []capabilityItem ) (mcpSubmission ,error ) { 150name := f .value (fieldName ) 151if ! mcpName .MatchString (name ) { 152return mcpSubmission {},errors .New ("name: 1–64 letters, digits, _ or -" ) 153 } 154if f .Editing == "" && slices .ContainsFunc (existing ,func (item capabilityItem )bool {return item .ID == name }) { 155return mcpSubmission {},fmt .Errorf ("a server called %s already exists; choose another name" ,name ) 156 } 157server := f .Base 158server .Type = f .Transport 159secrets := daemon.MCPSecretsPatch {} 160if f .Transport == "http" { 161raw := f .value (fieldURL ) 162parsed ,err := url .Parse (raw ) 163if raw == "" || err != nil || (parsed .Scheme != "http" && parsed .Scheme != "https" )|| parsed .Host == "" { 164return mcpSubmission {},errors .New ("enter a server URL starting with http:// or https://" ) 165 } 166server .URL ,server .Command ,server .Args ,server .CWD = raw ,"" ,nil ,"" 167if token := f .value (fieldToken );token == "-" { 168secrets .RemoveBearerToken = true 169 }else if token != "" { 170secrets .BearerToken = & token 171 } 172header ,value := f .value (fieldHeader ),f .value (fieldValue ) 173switch { 174case header == "" && value == "" : 175case ! mcpHeaderName .MatchString (header ): 176return mcpSubmission {},errors .New ("enter a valid HTTP header name" ) 177case value == "-" : 178secrets .Headers = map [string ]* string {header :nil } 179case value == "" : 180return mcpSubmission {},fmt .Errorf ("enter a value for %s, or - to remove it" ,header ) 181default : 182secrets .Headers = map [string ]* string {header :& value } 183 } 184secrets .ClearEnv = true 185 }else { 186argv ,err := splitCommand (f .Inputs [fieldCommand ].Value ()) 187if err != nil || len (argv )== 0 { 188return mcpSubmission {},errors .New ("enter the command that starts the server" ) 189 } 190server .Command ,server .Args ,server .URL = argv [0 ],argv [1 :],"" 191entries ,err := splitCommand (f .Inputs [fieldEnv ].Value ()) 192if err != nil { 193return mcpSubmission {},fmt .Errorf ("check environment variables: %w" ,err ) 194 } 195var env map [string ]* string 196for _ ,entry := range entries { 197key ,value ,ok := strings .Cut (entry ,"=" ) 198if ! ok || ! mcpEnvName .MatchString (key ) { 199return mcpSubmission {},errors .New ("enter environment variables as KEY=value pairs separated by spaces" ) 200 } 201if env == nil { 202env = map [string ]* string {} 203 } 204if value == "-" { 205env [key ]= nil 206 }else { 207env [key ]= & value 208 } 209 } 210if env != nil { 211secrets .Env = env 212 } 213secrets .RemoveBearerToken ,secrets .ClearHeaders = true ,true 214 } 215return mcpSubmission {Name :name ,Server :server ,Secrets :secrets },nil 216} 217 218// view is the form's rows; footer is its keys, shown in the screen's footer. 219func (f * mcpForm )view (width int ) []string { 220f .fit (width - 16 ) 221title := "add MCP server" 222if f .Editing != "" { 223title = "edit " + f .Editing 224 } 225rows := []string {DefaultStyles .Bold .Render (title )} 226for i ,key := range f .fields () { 227value := f .Transport 228if key != fieldTransport { 229value = f .Inputs [key ].View () 230 }else if f .Editing == "" { 231value = " http ‹stdio›" 232if f .Transport == "http" { 233value = "‹http› stdio " 234 } 235 } 236label := cmp .Or (mcpFieldLabels [key ],key ) 237rows = append (rows ,formRow (i == f .Focus ,label ,13 ,value ,width )) 238 } 239return rows 240} 241 242// footer explains the focused field and lists the form's keys. 243func (f * mcpForm )footer (width int )string { 244var own []hint 245note := "" 246switch f .current () { 247case fieldTransport : 248if f .Editing == "" { 249own = []hint {{"←→" ,"http or stdio" }} 250 } 251case fieldToken : 252note = "optional · sent as an Authorization: Bearer header" 253case fieldHeader ,fieldValue : 254note = "optional custom header, stored privately" 255case fieldEnv : 256note = "optional, stored privately" 257 } 258return formFooter (note ,width ,own ... ) 259} 260 261var nameUnsafe = regexp .MustCompile (`[^A-Za-z0-9_-]+` ) 262 263// nameFromURL suggests a server name from its host: mcp.linear.app → linear. 264func nameFromURL (raw string )string { 265parsed ,err := url .Parse (strings .TrimSpace (raw )) 266if err != nil || parsed .Hostname ()== "" { 267return "" 268 } 269labels := strings .Split (parsed .Hostname (),"." ) 270for len (labels )> 1 && (labels [0 ]== "mcp" || labels [0 ]== "api" || labels [0 ]== "www" ) { 271labels = labels [1 :] 272 } 273if len (labels )== 4 && strings .Trim (parsed .Hostname (),"0123456789." )== "" { 274return cleanName ("mcp-" + strings .ReplaceAll (parsed .Hostname (),"." ,"-" )) 275 } 276return cleanName (labels [0 ]) 277} 278 279// nameFromCommand suggests a name from the package or program a command 280// runs: npx -y @modelcontextprotocol/server-filesystem → filesystem. 281func nameFromCommand (argv []string )string { 282if len (argv )== 0 { 283return "" 284 } 285target := path .Base (argv [0 ]) 286switch target { 287case "npx" ,"uvx" ,"bunx" ,"pnpx" ,"pipx" ,"node" ,"python" ,"python3" ,"uv" ,"docker" : 288for _ ,arg := range argv [1 :] { 289if ! strings .HasPrefix (arg ,"-" )&& arg != "run" && arg != "tool" { 290target = arg 291break 292 } 293 } 294 } 295target = path .Base (target ) 296if at := strings .LastIndex (target ,"@" );at > 0 { 297target = target [:at ] 298 } 299for _ ,affix := range []string {"server-" ,"mcp-server-" ,"mcp-" } { 300target = strings .TrimPrefix (target ,affix ) 301 } 302for _ ,affix := range []string {"-mcp-server" ,"-mcp" ,"-server" } { 303target = strings .TrimSuffix (target ,affix ) 304 } 305return cleanName (target ) 306} 307 308func cleanName (name string )string { 309name = strings .Trim (nameUnsafe .ReplaceAllString (strings .ToLower (name ),"-" ),"-_" ) 310if len (name )> 64 { 311name = name [:64 ] 312 } 313return name 314} 315 316// splitCommand splits a command line on spaces, honouring single and double 317// quotes and backslash escapes, without invoking a shell. 318func splitCommand (line string ) ([]string ,error ) { 319var args []string 320var current strings.Builder 321inArg ,quote ,escaped := false ,rune (0 ),false 322for _ ,r := range line { 323switch { 324case escaped : 325current .WriteRune (r ) 326escaped = false 327case r == '\\' && quote != '\'' : 328escaped ,inArg = true ,true 329case quote != 0 : 330if r == quote { 331quote = 0 332 }else { 333current .WriteRune (r ) 334 } 335case r == '\'' || r == '"' : 336quote ,inArg = r ,true 337case r == ' ' || r == '\t' : 338if inArg { 339args = append (args ,current .String ()) 340current .Reset () 341inArg = false 342 } 343default : 344current .WriteRune (r ) 345inArg = true 346 } 347 } 348if quote != 0 || escaped { 349return nil ,errors .New ("unterminated quote" ) 350 } 351if inArg { 352args = append (args ,current .String ()) 353 } 354return args ,nil 355} 356 357func joinCommand (command string ,args []string )string { 358if command == "" { 359return "" 360 } 361parts := []string {quoteArg (command )} 362for _ ,arg := range args { 363parts = append (parts ,quoteArg (arg )) 364 } 365return strings .Join (parts ," " ) 366} 367 368func quoteArg (arg string )string { 369if arg != "" && ! strings .ContainsAny (arg ," \t'\"\\" ) { 370return arg 371 } 372return "'" + strings .ReplaceAll (arg ,"'" ,`'\''` )+ "'" 373}