Skip to content

Commit 01724d8

Browse files
committed
Add MCP Registry integration, secret handling, and individual env prompts
Registry integration: - RegistryClient queries registry.modelcontextprotocol.io with search, pagination, and 15-minute cache - ToMCPServerConfig maps npm/pip/pypi/oci/remote servers to config - polycode mcp search <query> and polycode mcp browse CLI commands - /mcp search <query> slash command in TUI - Wizard browse step fetches from live registry with offline fallback Secret handling: - EnvVarMeta preserves isSecret flag through config mapping - CLI browse uses huh.EchoModePassword for secret env vars - TUI wizard uses textinput.EchoPassword for secret env vars - Secrets stored in keyring with $KEYRING: references in config.yaml - auth.Store.Set errors now surfaced (not silently ignored) Individual env prompts: - Known env vars prompted one at a time with name/description labels - Secret vars masked, non-secrets shown in plain text - Previously-entered values shown above current prompt - Esc navigates backward through individual vars - Freeform KEY=value available after known vars Fixes: - saveMCPWizard validates config and checks duplicate names before save - Esc resets input state via prepareMCPInput - Template browse clears stale env state on re-selection - Edit mode infers secrets from $KEYRING: values - Remove dead renderMCPBrowse method
1 parent 7d6ef45 commit 01724d8

31 files changed

Lines changed: 2055 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [1.20.0] - 2026-03-28
10+
11+
### Added
12+
- **MCP Registry integration**: Live server discovery from the official MCP Registry (`registry.modelcontextprotocol.io`). Browse hundreds of servers with search, pagination, and 15-minute cache. Replaces the hardcoded 8-server list with a live registry, falling back offline.
13+
- **`polycode mcp search <query>`**: Search the MCP Registry from the CLI. Table output with name, transport, package, and description.
14+
- **`polycode mcp browse`**: Interactive CLI browser — search the registry, select a server, auto-populate config, confirm and save.
15+
- **`/mcp search <query>` slash command**: Search the registry from within the TUI chat.
16+
- **Secret env var handling**: Env vars marked as secrets (from registry metadata or templates) are prompted with masked/password input in both CLI and TUI. Secret values stored in keyring via `auth.Store` with `$KEYRING:` references in config.yaml.
17+
- **Individual env var prompting**: Known env vars from registry/templates are prompted one at a time with name, description, and "(secret)" labels. Secret vars use password masking. Previously-entered values shown above current prompt. Freeform KEY=value input available after known vars.
18+
- **Registry server config mapping**: npm→`npx -y`, pip/pypi→`uvx`, oci→`docker run --rm -i`, remote→URL. Env vars pre-populated with descriptions.
19+
20+
### Fixed
21+
- **Wizard saves now validated**: `saveMCPWizard` runs `Config.Validate()` and checks for duplicate server names before saving.
22+
- **Wizard Esc resets input state**: Back-navigation now calls `prepareMCPInput()` to reset values and EchoMode.
23+
- **Template browse clears stale env state**: Selecting a different server clears prior env vars, secrets, and descriptions.
24+
- **Keyring write errors surfaced**: CLI and TUI now fail the save flow with a clear message if `auth.Store.Set()` fails.
25+
- **Edit mode infers secrets from $KEYRING:** Editing an existing server rebuilds env order and marks `$KEYRING:` vars as secrets.
26+
927
## [1.19.3] - 2026-03-27
1028

1129
### Added

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ openspec/ → OpenSpec change artifacts (proposals, designs, specs,
3838
- **Config is the source of truth**: All provider setup flows through `config.Config`. TUI settings and YAML both write to the same config file.
3939
- **Token tracking**: `tokens.TokenTracker` accumulates per-provider usage. `tokens.MetadataStore` fetches model limits from litellm's JSON database with local cache + TTL.
4040
- **MCP client**: `mcp.MCPClient` manages connections to external MCP servers. Tool names are prefixed `mcp_{serverName}_{toolName}` and resolved via a lookup map (`toolIndex`) to avoid underscore-parsing ambiguity. Supports stdio (subprocess) and HTTP transports. A single multiplexed reader goroutine per stdio connection routes responses by request ID and dispatches notifications (e.g. `tools/list_changed`). Config changes apply at runtime via `Reconfigure()` without restart.
41+
- **MCP Registry**: `mcp.RegistryClient` queries `registry.modelcontextprotocol.io/v0/servers` for server discovery. Results are cached in-memory with 15-minute TTL. `ToMCPServerConfig()` maps registry metadata to config (npm→npx, pip/pypi→uvx, oci→docker, remote→URL). The wizard browse step and `/mcp search` both use the registry, falling back to the hardcoded `PopularMCPServers` list when offline.
4142

4243
## Code Conventions
4344

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ Models without reasoning support silently ignore the parameter.
194194
| `polycode export [--format md\|json]` | Export session |
195195
| `polycode import <file>` | Import session |
196196
| `polycode mcp list\|add\|remove\|test` | Manage MCP servers |
197+
| `polycode mcp search <query>` | Search the MCP server registry |
198+
| `polycode mcp browse` | Browse registry and install a server interactively |
197199
| `polycode skill list\|install\|remove` | Manage skills |
198200
| `polycode session list\|show\|delete` | Manage saved sessions |
199201
| `polycode auth login\|logout\|status` | Manage credentials |
@@ -273,7 +275,7 @@ Type `/` to open the command palette, or type commands directly:
273275
| `/name <name>` | Name the current session |
274276
| `/memory` | View repo memory |
275277
| `/skill [list\|install\|remove]` | Manage installed skills/plugins |
276-
| `/mcp [list\|status\|reconnect\|tools\|resources\|prompts\|add\|remove]` | Manage MCP servers |
278+
| `/mcp [list\|status\|reconnect\|tools\|resources\|prompts\|search\|add\|remove]` | Manage MCP servers |
277279
| `/settings` | Open provider settings (+ MCP servers with Tab) |
278280
| `/yolo` | Toggle auto-approve for all tool actions |
279281
| `/exit` | Quit polycode |

cmd/polycode/app.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,9 +788,47 @@ func startTUI(cfg *config.Config) error {
788788
}
789789
program.Send(tui.ConsensusChunkMsg{Delta: fmt.Sprintf("\nRemoved and disconnected MCP server '%s'.\n", serverName), Done: true})
790790

791+
case "search":
792+
query := strings.TrimSpace(args)
793+
if query == "" {
794+
program.Send(tui.ConsensusChunkMsg{Delta: "\nUsage: /mcp search <query>\n", Done: true})
795+
return
796+
}
797+
rc := mcp.NewRegistryClient()
798+
sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second)
799+
defer scancel()
800+
servers, _, err := rc.Search(sctx, query, 20)
801+
if err != nil {
802+
program.Send(tui.ConsensusChunkMsg{Delta: fmt.Sprintf("\nRegistry search failed: %v\n", err), Done: true})
803+
return
804+
}
805+
if len(servers) == 0 {
806+
program.Send(tui.ConsensusChunkMsg{Delta: fmt.Sprintf("\nNo servers found for '%s'.\n", query), Done: true})
807+
return
808+
}
809+
var sb strings.Builder
810+
sb.WriteString(fmt.Sprintf("\nMCP Registry — %d results for '%s'\n\n", len(servers), query))
811+
for _, s := range servers {
812+
name := s.Name
813+
if len(name) > 28 {
814+
name = name[:25] + "..."
815+
}
816+
desc := s.Description
817+
if len(desc) > 45 {
818+
desc = desc[:42] + "..."
819+
}
820+
transport := s.TransportLabel()
821+
if len(transport) > 12 {
822+
transport = transport[:12]
823+
}
824+
sb.WriteString(fmt.Sprintf(" %-28s %-12s %s\n", name, transport, desc))
825+
}
826+
sb.WriteString("\nUse /mcp add or polycode mcp browse to install a server.\n")
827+
program.Send(tui.ConsensusChunkMsg{Delta: sb.String(), Done: true})
828+
791829
default:
792830
program.Send(tui.ConsensusChunkMsg{
793-
Delta: "\nUsage: /mcp [list|status|reconnect [name]|tools [server]|resources [server]|prompts [server]|add|remove <name>]\n",
831+
Delta: "\nUsage: /mcp [list|status|reconnect|tools|resources|prompts|search|add|remove]\n",
794832
Done: true,
795833
})
796834
}
@@ -1672,6 +1710,52 @@ func startTUI(cfg *config.Config) error {
16721710
}()
16731711
})
16741712

1713+
// Set up MCP registry handlers for the wizard browse step.
1714+
registryClient := mcp.NewRegistryClient()
1715+
1716+
model.SetMCPRegistryFetchHandler(func() {
1717+
go func() {
1718+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1719+
defer cancel()
1720+
servers, _, err := registryClient.Search(ctx, "", 50)
1721+
if err != nil {
1722+
program.Send(tui.MCPRegistryResultsMsg{Error: err})
1723+
return
1724+
}
1725+
var results []tui.MCPRegistryResult
1726+
for i := range servers {
1727+
r := tui.MCPRegistryResult{
1728+
Name: servers[i].Name,
1729+
Description: servers[i].Description,
1730+
TransportLabel: servers[i].TransportLabel(),
1731+
PackageID: servers[i].PackageIdentifier(),
1732+
ServerData: &servers[i],
1733+
}
1734+
// Attach env var metadata for individual prompting.
1735+
if len(servers[i].Packages) > 0 {
1736+
for _, ev := range servers[i].Packages[0].EnvVars {
1737+
r.EnvVars = append(r.EnvVars, tui.MCPRegistryEnvMeta{
1738+
Name: ev.Name,
1739+
Description: ev.Description,
1740+
IsSecret: ev.IsSecret,
1741+
IsRequired: ev.IsRequired,
1742+
})
1743+
}
1744+
}
1745+
results = append(results, r)
1746+
}
1747+
program.Send(tui.MCPRegistryResultsMsg{Servers: results})
1748+
}()
1749+
})
1750+
1751+
model.SetMCPRegistrySelectHandler(func(result tui.MCPRegistryResult) config.MCPServerConfig {
1752+
if srv, ok := result.ServerData.(*mcp.RegistryServer); ok {
1753+
cfg, _ := mcp.ToMCPServerConfig(*srv)
1754+
return cfg
1755+
}
1756+
return config.MCPServerConfig{Name: result.Name}
1757+
})
1758+
16751759
// Create the Bubble Tea program AFTER all handlers are wired,
16761760
// so the model copy Bubble Tea receives has all callbacks set.
16771761
program = tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())

cmd/polycode/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,18 @@ func main() {
345345
Args: cobra.MaximumNArgs(1),
346346
RunE: runMCPTest,
347347
}
348-
mcpCmd.AddCommand(mcpListCmd, mcpAddCmd, mcpRemoveCmd, mcpTestCmd)
348+
mcpSearchCmd := &cobra.Command{
349+
Use: "search <query>",
350+
Short: "Search the MCP server registry",
351+
Args: cobra.MinimumNArgs(1),
352+
RunE: runMCPSearch,
353+
}
354+
mcpBrowseCmd := &cobra.Command{
355+
Use: "browse",
356+
Short: "Browse and install from the MCP server registry",
357+
RunE: runMCPBrowse,
358+
}
359+
mcpCmd.AddCommand(mcpListCmd, mcpAddCmd, mcpRemoveCmd, mcpTestCmd, mcpSearchCmd, mcpBrowseCmd)
349360
rootCmd.AddCommand(mcpCmd)
350361

351362
if err := rootCmd.Execute(); err != nil {

cmd/polycode/mcp.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/charmbracelet/huh"
1010
"github.com/spf13/cobra"
1111

12+
"github.com/izzoa/polycode/internal/auth"
1213
"github.com/izzoa/polycode/internal/config"
1314
"github.com/izzoa/polycode/internal/mcp"
1415
)
@@ -257,3 +258,184 @@ func runMCPTest(cmd *cobra.Command, args []string) error {
257258
fmt.Printf(" ✓ Connected successfully (%d tools discovered)\n", toolCount)
258259
return nil
259260
}
261+
262+
func runMCPSearch(cmd *cobra.Command, args []string) error {
263+
query := strings.Join(args, " ")
264+
265+
rc := mcp.NewRegistryClient()
266+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
267+
defer cancel()
268+
269+
servers, _, err := rc.Search(ctx, query, 20)
270+
if err != nil {
271+
return fmt.Errorf("registry search failed: %w", err)
272+
}
273+
274+
if len(servers) == 0 {
275+
fmt.Printf("No servers found for '%s'.\n", query)
276+
return nil
277+
}
278+
279+
fmt.Printf("MCP Registry — %d results for '%s'\n\n", len(servers), query)
280+
fmt.Printf(" %-28s %-16s %-30s %s\n", "NAME", "TRANSPORT", "PACKAGE", "DESCRIPTION")
281+
fmt.Printf(" %-28s %-16s %-30s %s\n", "----", "---------", "-------", "-----------")
282+
for _, s := range servers {
283+
fmt.Printf(" %-28s %-16s %-30s %s\n",
284+
truncate(s.Name, 26),
285+
truncate(s.TransportLabel(), 14),
286+
truncate(s.PackageIdentifier(), 28),
287+
truncate(s.Description, 40),
288+
)
289+
}
290+
return nil
291+
}
292+
293+
func truncate(s string, maxLen int) string {
294+
if len(s) <= maxLen {
295+
return s
296+
}
297+
if maxLen <= 3 {
298+
return s[:maxLen]
299+
}
300+
return s[:maxLen-3] + "..."
301+
}
302+
303+
func runMCPBrowse(cmd *cobra.Command, args []string) error {
304+
// Search query.
305+
var query string
306+
err := huh.NewInput().
307+
Title("Search MCP Registry").
308+
Placeholder("e.g., github, database, filesystem").
309+
Value(&query).
310+
Run()
311+
if err != nil || query == "" {
312+
return nil // cancelled
313+
}
314+
315+
rc := mcp.NewRegistryClient()
316+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
317+
defer cancel()
318+
319+
servers, _, err := rc.Search(ctx, query, 20)
320+
if err != nil {
321+
return fmt.Errorf("registry search failed: %w", err)
322+
}
323+
324+
if len(servers) == 0 {
325+
fmt.Printf("No servers found for '%s'.\n", query)
326+
return nil
327+
}
328+
329+
// Build selection list.
330+
opts := make([]huh.Option[int], len(servers))
331+
for i, s := range servers {
332+
desc := s.Description
333+
if len(desc) > 60 {
334+
desc = desc[:57] + "..."
335+
}
336+
label := fmt.Sprintf("%-25s %s", s.Name, desc)
337+
opts[i] = huh.NewOption(label, i)
338+
}
339+
340+
var selected int
341+
err = huh.NewSelect[int]().
342+
Title(fmt.Sprintf("Select server (%d results)", len(servers))).
343+
Options(opts...).
344+
Value(&selected).
345+
Run()
346+
if err != nil {
347+
return nil // cancelled
348+
}
349+
350+
// Map to config.
351+
srv := servers[selected]
352+
cfg, envMeta := mcp.ToMCPServerConfig(srv)
353+
354+
// Show what will be added.
355+
// Preflight: load config and check duplicates before prompting user.
356+
appCfg, err := config.Load()
357+
if err != nil {
358+
return fmt.Errorf("loading config: %w", err)
359+
}
360+
for _, s := range appCfg.MCP.Servers {
361+
if s.Name == cfg.Name {
362+
return fmt.Errorf("MCP server %q already exists — use a different name or remove the existing one first", cfg.Name)
363+
}
364+
}
365+
366+
fmt.Printf("\nServer: %s\n", srv.Name)
367+
fmt.Printf(" Description: %s\n", srv.Description)
368+
if cfg.Command != "" {
369+
cmdStr := cfg.Command
370+
if len(cfg.Args) > 0 {
371+
cmdStr += " " + strings.Join(cfg.Args, " ")
372+
}
373+
fmt.Printf(" Command: %s\n", cmdStr)
374+
}
375+
if cfg.URL != "" {
376+
fmt.Printf(" URL: %s\n", cfg.URL)
377+
}
378+
if len(cfg.Env) > 0 {
379+
envKeys := make([]string, 0, len(cfg.Env))
380+
for k := range cfg.Env {
381+
envKeys = append(envKeys, k)
382+
}
383+
fmt.Printf(" Env vars needed: %s\n", strings.Join(envKeys, ", "))
384+
}
385+
386+
// Confirm.
387+
var confirm bool
388+
err = huh.NewConfirm().
389+
Title("Add this server?").
390+
Value(&confirm).
391+
Run()
392+
if err != nil || !confirm {
393+
return nil
394+
}
395+
396+
// Build secret lookup from metadata.
397+
secretVars := make(map[string]bool)
398+
for _, m := range envMeta {
399+
if m.IsSecret {
400+
secretVars[m.Name] = true
401+
}
402+
}
403+
404+
// Prompt for required env var values.
405+
if len(cfg.Env) > 0 {
406+
store := auth.NewStore()
407+
for k := range cfg.Env {
408+
var val string
409+
input := huh.NewInput().
410+
Title(fmt.Sprintf("Value for %s", k)).
411+
Value(&val)
412+
if secretVars[k] {
413+
input = input.EchoMode(huh.EchoModePassword)
414+
}
415+
err = input.Run()
416+
if err != nil {
417+
return nil
418+
}
419+
if secretVars[k] && val != "" {
420+
keyringKey := fmt.Sprintf("mcp_%s_%s", cfg.Name, k)
421+
if err := store.Set(keyringKey, val); err != nil {
422+
return fmt.Errorf("failed to store secret %s: %w", k, err)
423+
}
424+
cfg.Env[k] = "$KEYRING:" + keyringKey
425+
} else {
426+
cfg.Env[k] = val
427+
}
428+
}
429+
}
430+
431+
appCfg.MCP.Servers = append(appCfg.MCP.Servers, cfg)
432+
if err := appCfg.Validate(); err != nil {
433+
return fmt.Errorf("invalid configuration: %w", err)
434+
}
435+
if err := appCfg.Save(); err != nil {
436+
return fmt.Errorf("saving config: %w", err)
437+
}
438+
439+
fmt.Printf("\nMCP server '%s' added. %d server(s) configured.\n", cfg.Name, len(appCfg.MCP.Servers))
440+
return nil
441+
}

0 commit comments

Comments
 (0)