Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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|<id>)",
Expand Down
14 changes: 14 additions & 0 deletions events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
116 changes: 116 additions & 0 deletions mcp.go
Original file line number Diff line number Diff line change
@@ -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 <sub>" 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 <server>") 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
}
}
120 changes: 120 additions & 0 deletions mcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 5 additions & 3 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading