From cc939c6770c62cad924928eced4b6e44093990c1 Mon Sep 17 00:00:00 2001 From: tdwd Date: Tue, 4 Aug 2026 11:06:01 +0200 Subject: [PATCH] Add /mcp server picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare /mcp forwarded to the headless subprocess did nothing useful — the interactive server modal is a real-terminal feature the print-mode CLI can't render, so it just replied with a one-line text status. Reconstruct the modal from the mcp_servers list that rides system:init: parse and cache it (events.go/stream.go), and add an in-process /mcp command that opens a picker of servers + status, with a per-server action menu (reconnect / enable / disable) forwarded as the CLI's own subcommands. The list only arrives with the first turn's init, so the very first /mcp (before any turn) has nothing cached. Silently prime it with a free, 0-turn /mcp fetch, swallow the text echo, and auto-open the picker when the list lands. OAuth login for needs-auth servers stays terminal-only. --- commands.go | 7 +++ events.go | 14 ++++++ keys.go | 14 ++++++ mcp.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++ mcp_test.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++ model.go | 8 ++-- stream.go | 18 ++++++++ 7 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 mcp.go create mode 100644 mcp_test.go diff --git a/commands.go b/commands.go index 4d0043e..4c52871 100644 --- a/commands.go +++ b/commands.go @@ -63,6 +63,13 @@ func slashCommands() []slashCmd { return *m, nil }, }, + { + name: "mcp", + desc: "manage MCP servers — status, reconnect/enable/disable", + exec: func(m *model, arg string) (model, tea.Cmd) { + return m.mcpCommand(arg) + }, + }, { name: "model", desc: "switch model (opus|sonnet|haiku|)", diff --git a/events.go b/events.go index b2b77cd..b342b64 100644 --- a/events.go +++ b/events.go @@ -38,6 +38,10 @@ type Envelope struct { Outcome string `json:"outcome"` Stderr string `json:"stderr"` + // system/init also reports the configured MCP servers and their status; + // drives the /mcp picker (see mcp.go). Only present on the init line. + MCPServers []MCPServerInfo `json:"mcp_servers"` + // control_response-only (e.g. the reply to our initialize handshake) Response *ControlResp `json:"response"` } @@ -78,6 +82,16 @@ type AgentInfo struct { Description string `json:"description"` } +// MCPServerInfo is one entry in the system:init mcp_servers list — a configured +// MCP server and its connection status ("connected", "needs-auth", "failed", +// "disabled", …). Drives the /mcp picker (mcp.go). The interactive server modal +// is a real-terminal feature the headless CLI can't render, so we reconstruct +// it from this list. +type MCPServerInfo struct { + Name string `json:"name"` + Status string `json:"status"` +} + // ModelChoice is one entry in the initialize model list — the same set the // interactive `claude` /model menu shows. Value is what set_model takes // ("default", "opus[1m]", "sonnet", …); DisplayName/Description label the row. diff --git a/keys.go b/keys.go index cf192ed..d36b1e9 100644 --- a/keys.go +++ b/keys.go @@ -109,6 +109,20 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { case "model": m.applyModel(chosen) return m, nil, true + case "mcp": + // A server was chosen — open its (status-dependent) action menu. + status := "" + for _, s := range m.mcpServers { + if s.Name == chosen { + status = s.Status + break + } + } + m.picker = newPicker("mcpaction", "MCP · "+chosen, mcpActionItems(chosen, status), m.w, m.h) + return m, nil, true + case "mcpaction": + // chosen is the full /mcp argument, e.g. "reconnect foo". + return m, m.sendMCP(chosen), true case "settings": // Top-level menu: open the chosen setting's picker, pre-positioned. switch chosen { diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..20397e1 --- /dev/null +++ b/mcp.go @@ -0,0 +1,116 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// mcpCommand implements /mcp. Bare /mcp opens the server picker; the rich +// interactive modal is a real-terminal feature the headless CLI can't render, +// so cathode reconstructs it from the server list. That list only rides the +// first turn's system:init (not the initialize handshake), so on the very first +// /mcp we don't have it yet — primeMCP fetches it silently and the picker opens +// when it lands (see handleEvent). With an argument ("reconnect all", "disable +// foo") we defer to claude's own /mcp, which owns those subcommands. +func (m *model) mcpCommand(arg string) (model, tea.Cmd) { + if strings.TrimSpace(arg) == "" { + if len(m.mcpServers) > 0 { + m.openMCPPicker() + return *m, nil + } + if !m.busy { + return *m, m.primeMCP() + } + } + return *m, m.sendMCP(arg) +} + +// openMCPPicker shows the server picker, or an info line when nothing's +// configured — so /mcp never opens an empty dialog. +func (m *model) openMCPPicker() { + if len(m.mcpServers) == 0 { + m.add(entInfo, "no MCP servers configured") + return + } + m.picker = newPicker("mcp", "MCP SERVERS", mcpItems(m.mcpServers), m.w, m.h) +} + +// primeMCP fetches the server list by sending a bare /mcp to claude (a free, +// 0-turn client command that emits system:init with the list). It's sent raw — +// no user bubble, no history — and mcpPriming makes handleEvent swallow the text +// echo and open the picker on the result. Only used when the list isn't cached +// yet and we're idle; otherwise the picker opens straight away. +func (m *model) primeMCP() tea.Cmd { + if err := m.engine.Send("/mcp"); err != nil { + m.add(entError, "mcp: "+err.Error()) + return nil + } + m.mcpPriming = true + m.busy = true + return m.armSpinnerIfNeeded() +} + +// sendMCP forwards a "/mcp " management turn to claude, guarding against +// injecting it mid-turn (that would read as steering, not a command). Shared by +// the bare-command fallback and the picker's action selection (see keys.go). +func (m *model) sendMCP(args string) tea.Cmd { + line := "/mcp" + if args = strings.TrimSpace(args); args != "" { + line += " " + args + } + if m.busy { + m.add(entInfo, "busy — try "+line+" after the current turn") + return nil + } + return m.sendTurn(line) +} + +// mcpItems renders the server list into picker rows (name + status). Selecting a +// row opens that server's action menu (see mcpActionItems). +func mcpItems(servers []MCPServerInfo) []pickerItem { + items := make([]pickerItem, 0, len(servers)) + for _, s := range servers { + items = append(items, pickerItem{id: s.Name, title: s.Name, subtitle: mcpStatusLabel(s.Status)}) + } + return items +} + +// mcpActionItems is the per-server action menu. Each id is the full /mcp +// argument ("reconnect ") so the dispatcher just forwards "/mcp "+id. +// Actions are contextual: a disabled server offers enable; anything else offers +// reconnect (retry connection/auth) and disable. +func mcpActionItems(server, status string) []pickerItem { + if status == "disabled" { + return []pickerItem{ + {id: "enable " + server, title: "enable", subtitle: "turn this server back on"}, + {id: "reconnect " + server, title: "reconnect", subtitle: "enable and retry the connection"}, + } + } + return []pickerItem{ + {id: "reconnect " + server, title: "reconnect", subtitle: "retry the connection / auth"}, + {id: "disable " + server, title: "disable", subtitle: "turn this server off"}, + } +} + +// mcpStatusLabel humanises a server's raw status for the picker subtitle. Auth +// is called out because a needs-auth server can't be logged in headlessly — the +// OAuth handshake needs a real `claude mcp` / interactive `/mcp` session. +func mcpStatusLabel(status string) string { + switch status { + case "connected": + return "● connected" + case "needs-auth", "needs_auth": + return "○ needs auth — run `claude mcp` in a terminal to log in" + case "disabled": + return "○ disabled" + case "failed": + return "✗ failed — try reconnect" + case "pending", "connecting": + return "… connecting" + case "": + return "status unknown" + default: + return status + } +} diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..4d283ef --- /dev/null +++ b/mcp_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// The server list rides the system:init line (not the initialize handshake), +// so handleEvent must parse and cache it for the /mcp picker. +func TestMCPServersParsedFromInit(t *testing.T) { + raw := `{"type":"system","subtype":"init","session_id":"","model":"opus", + "mcp_servers":[{"name":"stripe","status":"needs-auth"}, + {"name":"gh","status":"connected"}]}` + var e Envelope + if err := json.Unmarshal([]byte(raw), &e); err != nil { + t.Fatalf("unmarshal init: %v", err) + } + // Session "" keeps sessions.Touch a no-op so a bare &model{} is safe here. + m := &model{} + m.handleEvent(e) + if len(m.mcpServers) != 2 { + t.Fatalf("want 2 cached servers, got %d", len(m.mcpServers)) + } + if m.mcpServers[0].Name != "stripe" || m.mcpServers[0].Status != "needs-auth" { + t.Errorf("first server mis-parsed: %+v", m.mcpServers[0]) + } +} + +// Bare /mcp opens the picker once the server list is known; before that (no +// servers cached) it must NOT open an empty picker — it falls through to the +// forward path instead. +func TestMCPCommandOpensPickerWhenKnown(t *testing.T) { + m := &model{mcpServers: []MCPServerInfo{{Name: "gh", Status: "connected"}}} + nm, _ := m.mcpCommand("") + if nm.picker == nil { + t.Fatal("bare /mcp with a known server list should open the picker") + } + if nm.picker.kind != "mcp" { + t.Errorf("picker kind = %q, want \"mcp\"", nm.picker.kind) + } + if len(nm.picker.items) != 1 || nm.picker.items[0].id != "gh" { + t.Errorf("picker rows mis-built: %+v", nm.picker.items) + } +} + +// The first-invocation path: /mcp is typed before any turn, so the list isn't +// cached. A silent prime fetches it; when the init lands and the result closes +// the turn, the picker must open and the text echo / "— done —" line must be +// swallowed so the transcript stays clean. +func TestMCPPrimeOpensPickerOnResult(t *testing.T) { + m := &model{mcpPriming: true} + + // The prime's own text status must not reach the transcript. + m.handleEvent(Envelope{Type: "assistant", Message: &APIMessage{ + Content: []ContentBlock{{Type: "text", Text: "2 MCP server(s): ..."}}, + }}) + if len(m.entries) != 0 { + t.Fatalf("primed /mcp echo leaked into transcript: %+v", m.entries) + } + + // init caches the server list (Session "" keeps sessions.Touch a no-op). + m.handleEvent(Envelope{Type: "system", Subtype: "init", + MCPServers: []MCPServerInfo{{Name: "gh", Status: "connected"}}}) + + // result closes the prime: picker opens, no "— done —" line, flag cleared. + m.handleEvent(Envelope{Type: "result", Subtype: "success"}) + if m.mcpPriming { + t.Error("mcpPriming should be cleared after the result") + } + if m.busy { + t.Error("busy should be cleared after the result") + } + if m.picker == nil || m.picker.kind != "mcp" { + t.Fatalf("picker should open on result, got %+v", m.picker) + } + for _, e := range m.entries { + if strings.Contains(e.text, "done") { + t.Errorf("prime should not print a done line, got %q", e.text) + } + } +} + +// A prime that comes back with no servers configured opens no dialog — it says +// so plainly instead. +func TestMCPPrimeNoServers(t *testing.T) { + m := &model{mcpPriming: true} + m.handleEvent(Envelope{Type: "result", Subtype: "success"}) + if m.picker != nil { + t.Error("no picker should open when no servers are configured") + } + if len(m.entries) != 1 || !strings.Contains(m.entries[0].text, "no MCP servers") { + t.Errorf("expected a 'no MCP servers' info line, got %+v", m.entries) + } +} + +// Actions are contextual: a disabled server can be enabled; a live one can be +// reconnected or disabled. Each id is the exact "/mcp" argument to forward. +func TestMCPActionItemsContextual(t *testing.T) { + enabled := mcpActionItems("gh", "connected") + if len(enabled) != 2 || enabled[0].id != "reconnect gh" || enabled[1].id != "disable gh" { + t.Errorf("connected server actions = %+v", enabled) + } + + disabled := mcpActionItems("gh", "disabled") + if len(disabled) == 0 || disabled[0].id != "enable gh" { + t.Errorf("disabled server should offer enable first, got %+v", disabled) + } +} + +// needs-auth is surfaced with a hint that login is a terminal-only step, since +// the headless CLI can't run the OAuth flow. +func TestMCPStatusLabelAuthHint(t *testing.T) { + if got := mcpStatusLabel("needs-auth"); !strings.Contains(got, "terminal") { + t.Errorf("needs-auth label should point to a terminal, got %q", got) + } + if got := mcpStatusLabel("weird-new-status"); got != "weird-new-status" { + t.Errorf("unknown status should pass through verbatim, got %q", got) + } +} diff --git a/model.go b/model.go index 937e930..e6e0124 100644 --- a/model.go +++ b/model.go @@ -139,9 +139,11 @@ type model struct { mode string session string modelID string - models []ModelChoice // model menu from the initialize handshake; drives /model (see models.go) - commands []CommandInfo // command list from the handshake; merged into the palette (built-ins + skills + plugins) - agents []AgentInfo // subagent list from the handshake; shown by /agents + models []ModelChoice // model menu from the initialize handshake; drives /model (see models.go) + commands []CommandInfo // command list from the handshake; merged into the palette (built-ins + skills + plugins) + agents []AgentInfo // subagent list from the handshake; shown by /agents + mcpServers []MCPServerInfo // MCP servers + status from system:init; drives /mcp (see mcp.go) + mcpPriming bool // true while a silent /mcp fetch is in flight; its echo is swallowed and the picker opens on result (mcp.go) lastCost float64 // Running token totals across the session. ctxTokens is the most recent // turn's "live" context size (input + cache_read + cache_creation), which diff --git a/stream.go b/stream.go index 688850c..e6a5a9c 100644 --- a/stream.go +++ b/stream.go @@ -17,6 +17,12 @@ func (m *model) handleEvent(e Envelope) { m.session, m.modelID = e.Session, e.Model cwd, _ := os.Getwd() m.sessions.Touch(e.Session, e.Model, cwd, "", time.Now()) + // The server list only arrives here (not in the initialize handshake), + // so cache it for the /mcp picker; keep the last non-empty snapshot the + // way commands/agents are handled. + if len(e.MCPServers) > 0 { + m.mcpServers = e.MCPServers + } m.add(entInfo, fmt.Sprintf("— session %s · %s —", short(e.Session), e.Model)) case "hook_response": // Routine successful hooks (SessionStart, PreToolUse, …) fire constantly @@ -53,6 +59,11 @@ func (m *model) handleEvent(e Envelope) { if e.Message == nil { return } + // A silent /mcp prime (mcp.go): swallow its text status — the picker opens + // on the result instead, so the transcript stays clean. + if m.mcpPriming { + return + } if u := e.Message.Usage; u != nil { // observeCtx records the live context size and auto-grows ctxLimit // past it (200K → 500K → 1M → 2M) so the long-context beta doesn't @@ -131,6 +142,13 @@ func (m *model) handleEvent(e Envelope) { case "result": m.busy = false m.lastCost = e.TotalCostUSD + // End of a silent /mcp prime: don't print the "— done —" line — just open + // the picker now that init has cached the server list (mcp.go). + if m.mcpPriming { + m.mcpPriming = false + m.openMCPPicker() + return + } if e.IsError { m.add(entError, "✗ "+e.Result) }