From 8767c5f6352faeebb7aa8f0623b6bc324c667699 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 30 Sep 2026 15:10:26 +0200 Subject: [PATCH 1/2] feat(tui): add /mcp fast-jump command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /mcp opens the tools drawer focused on the first MCP server; /mcp jumps to that server (case-insensitive), with a not-found note for unknown names and a no-MCP-servers empty state. Reuses the existing /api/mcp fetch — no protocol changes. --- README.md | 3 +- internal/tui/commands.go | 5 ++ internal/tui/commands_e2e_test.go | 18 ++++ internal/tui/mcp_command_test.go | 134 ++++++++++++++++++++++++++++++ internal/tui/mgmt.go | 29 +++++++ internal/tui/model.go | 2 + 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 internal/tui/mcp_command_test.go diff --git a/README.md b/README.md index fa64ff5..c7f8280 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,7 @@ full command and press `⏎`. | `/memory` | Facts by target, pending-episode promote, consolidate | | `/skills` | Skill provenance badges & promote | | `/tools` | Tool registry with enabled state & MCP servers | +| `/mcp` | MCP servers — `/mcp [name]` jumps to one in the tools drawer | | `/config` | Sanitized config, lifetime usage, connections (kick) | | `/model [name]` | Switch model (opens a picker with no argument) | | `/thinking [disabled\|low\|medium\|high]` | Set reasoning depth (`on` → `medium`, `off` → `disabled`, `inherit` restores the odek serve default; bare `/thinking` opens a picker) | @@ -522,7 +523,7 @@ full command and press `⏎`. ### The management drawer `/sessions`, `/runs`, `/agents`, `/jobs`, `/events`, `/plan`, `/memory`, -`/skills`, `/tools`, and `/config` all open tabs of **one drawer** that +`/skills`, `/tools`, `/mcp`, and `/config` all open tabs of **one drawer** that sits as a **bottom sheet** — about eight transcript rows stay visible above it (full-bleed only when the terminal is too short) — with a shared grammar: diff --git a/internal/tui/commands.go b/internal/tui/commands.go index a68847f..5e1305b 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -118,6 +118,11 @@ func slashCommands() []command { {"tools", "tool registry & MCP servers", func(m *Model, _ string) tea.Cmd { return m.openTools() }}, + {"mcp", "MCP servers — /mcp [name] jumps to one", func(m *Model, args string) tea.Cmd { + m.mcpJump = true + m.mcpFocus = strings.TrimSpace(args) + return m.openTools() + }}, {"config", "server config, usage & connections", func(m *Model, _ string) tea.Cmd { return m.openConfig() }}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 2c0d908..0147e33 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -242,6 +242,24 @@ func TestE2EAllCommands(t *testing.T) { t.Error("/tools fetched no rows") } }, + "/mcp": func(t *testing.T, m *Model) { + if m.panel != panelTools { + t.Fatalf("panel = %d, want tools", m.panel) + } + if r := m.toolSelected(); r == nil || r.kind != "mcp" { + t.Fatalf("/mcp selection = %+v, want the mcp row", r) + } + }, + "/mcp fs": func(t *testing.T, m *Model) { + if r := m.toolSelected(); r == nil || r.kind != "mcp" || r.text != "fs" { + t.Fatalf("/mcp fs selection = %+v, want the fs row", r) + } + }, + "/mcp nope": func(t *testing.T, m *Model) { + if !strings.Contains(m.panelMsg, "no MCP server named") { + t.Errorf("/mcp nope panelMsg = %q, want a not-found note", m.panelMsg) + } + }, "/config": func(t *testing.T, m *Model) { if m.panel != panelConfig { t.Fatalf("panel = %d, want config", m.panel) diff --git a/internal/tui/mcp_command_test.go b/internal/tui/mcp_command_test.go new file mode 100644 index 0000000..659517e --- /dev/null +++ b/internal/tui/mcp_command_test.go @@ -0,0 +1,134 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// runSlash executes a "/name args" line through the command registry and +// feeds the resulting message back into the model (the e2e command path). +func runSlash(t *testing.T, m *Model, line string) { + t.Helper() + name := strings.TrimPrefix(line, "/") + args := "" + if i := strings.IndexAny(name, " \t"); i >= 0 { + name, args = name[:i], strings.TrimSpace(name[i+1:]) + } + var cmd tea.Cmd + if c := findCommand(name); c != nil { + cmd = c.run(m, args) + } else { + t.Fatalf("command %q not in registry", name) + } + if cmd != nil { + m.Update(exec(cmd)) + } +} + +func findCommand(name string) *command { + for i := range slashCommands() { + if slashCommands()[i].name == name { + return &slashCommands()[i] + } + } + return nil +} + +// TestMCPCommandRegistry verifies the /mcp registry entry exists and is +// offered by the slash popup and the /help card. +func TestMCPCommandRegistry(t *testing.T) { + c := findCommand("mcp") + if c == nil { + t.Fatal("/mcp not registered in slashCommands()") + } + if c.desc == "" { + t.Fatal("/mcp has no description") + } + m := wired(t) + if !commandOffered(m, *c) { + t.Error("/mcp must be offered unconditionally (server-level, no session gate)") + } + m.ta.SetValue("/") + m.Update(key("m")) + m.Update(key("c")) + m.Update(key("p")) + found := false + for _, it := range m.ac.items { + if it.ID == "/mcp" { + found = true + } + } + if !found { + t.Errorf("slash popup missing /mcp: %+v", m.ac.items) + } +} + +// TestMCPBareOpensToolsTabOnMCPSection verifies bare /mcp opens the drawer +// Tools tab with the first MCP server row focused. +func TestMCPBareOpensToolsTabOnMCPSection(t *testing.T) { + m := wired(t) + runSlash(t, m, "/mcp") + if m.panel != panelTools { + t.Fatalf("panel = %d, want tools", m.panel) + } + if len(m.toolRows) == 0 { + t.Fatal("toolRows empty after /mcp") + } + sel := m.toolSelected() + if sel == nil || sel.kind != "mcp" { + t.Fatalf("selection = %+v, want first mcp row focused", sel) + } + if sel.text != "fs" { + t.Fatalf("focused server = %q, want fs", sel.text) + } +} + +// TestMCPNamedJump verifies /mcp focuses that server's row +// case-insensitively. +func TestMCPNamedJump(t *testing.T) { + m := wired(t) + runSlash(t, m, "/mcp FS") + if m.panel != panelTools { + t.Fatalf("panel = %d, want tools", m.panel) + } + sel := m.toolSelected() + if sel == nil || sel.kind != "mcp" || sel.text != "fs" { + t.Fatalf("selection = %+v, want mcp row fs", sel) + } +} + +// TestMCPUnknownName verifies an unknown server name reports in the drawer +// footer without moving the tab. +func TestMCPUnknownName(t *testing.T) { + m := wired(t) + runSlash(t, m, "/mcp nope") + if m.panel != panelTools { + t.Fatalf("panel = %d, want tools", m.panel) + } + if !strings.Contains(m.panelMsg, "no MCP server named") { + t.Errorf("panelMsg = %q, want a not-found note", m.panelMsg) + } +} + +// TestMCPZeroServers verifies the zero-server empty state: the Tools tab +// still opens (it has native tools) with a "no MCP servers configured" note. +func TestMCPZeroServers(t *testing.T) { + m := wired(t) + m.panel = panelTools + m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 0, tls: []client.Tool{ + {Name: "shell", Enabled: true}, {Name: "read_file", Enabled: true}, + }}) + if m.panel != panelTools { + t.Fatalf("panel = %d, want tools", m.panel) + } + if !strings.Contains(m.panelMsg, "no MCP servers configured") { + t.Errorf("panelMsg = %q, want the zero-servers note", m.panelMsg) + } + if len(m.toolRows) != 2 { + t.Fatalf("toolRows = %d, want the 2 built-ins", len(m.toolRows)) + } +} diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 7eac09c..e10e2da 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -220,6 +220,35 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } else { m.panelMsg = fmt.Sprintf("%d built-ins · %d MCP servers", len(msg.tls), msg.mcpN) } + // One-shot /mcp focus: land on the first MCP row (bare) or the + // named server (case-insensitive). Consumed exactly once. + if m.mcpJump { + m.mcpJump = false + if focus := m.mcpFocus; focus != "" { + m.mcpFocus = "" + found := false + for i, r := range m.toolRows { + if r.kind == "mcp" && strings.EqualFold(r.id, focus) { + m.panelSel = i + found = true + break + } + } + if !found { + m.panelMsg = "no MCP server named '" + focus + "' — /mcp lists all" + } + } else { + for i, r := range m.toolRows { + if r.kind == "mcp" { + m.panelSel = i + break + } + } + } + } + if msg.mcpN == 0 && len(m.toolRows) > 0 { + m.panelMsg = fmt.Sprintf("%d built-ins · no MCP servers configured", len(msg.tls)) + } case panelConfig: m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con) m.panelMsg = "" diff --git a/internal/tui/model.go b/internal/tui/model.go index 5806195..c206fca 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -332,6 +332,8 @@ type Model struct { memTarget string // add-fact editor target ("user" | "env") skills []client.Skill toolRows []toolRow + mcpJump bool // a /mcp invocation armed a one-shot focus + mcpFocus string // /mcp focus target, consumed on fetch cfgRows []cfgRow shutdownReq bool // shutdown sent — the socket drop is expected, not a failure From 16a7256d50ec6ba10c86915a182f6dd114952051 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 30 Sep 2026 15:17:34 +0200 Subject: [PATCH 2/2] fix(tui): disarm the /mcp one-shot focus on tab switch and fetch error A /mcp jump followed by a drawer tab switch before the tools fetch landed stayed armed and re-applied a stale focus on a later unrelated /tools load; a failed fetch leaked it the same way. Both paths now consume the one-shot, the not-found note survives the zero-server note on the same frame, and negative tests pin the semantics. --- internal/tui/drawer.go | 2 ++ internal/tui/mcp_command_test.go | 56 ++++++++++++++++++++++++++++++++ internal/tui/mgmt.go | 19 +++++++---- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index 04ffc1b..f557d73 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -250,6 +250,8 @@ func (m *Model) switchDrawerTab(mode panelMode) tea.Cmd { m.confirm = confirmNone // a gate never survives a tab change m.panelDetail = false // nor does an open detail view m.detailScroll = 0 + m.mcpJump = false // nor does the /mcp one-shot focus + m.mcpFocus = "" for _, t := range drawerTabs() { if t.mode == mode { return t.open(m) diff --git a/internal/tui/mcp_command_test.go b/internal/tui/mcp_command_test.go index 659517e..d832a3e 100644 --- a/internal/tui/mcp_command_test.go +++ b/internal/tui/mcp_command_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "strings" "testing" @@ -114,6 +115,61 @@ func TestMCPUnknownName(t *testing.T) { } } +// TestMCPOneShotSemantics pins the one-shot focus contract: a /mcp jump +// consumed by one fetch never re-applies, and a tab switch before the fetch +// lands disarms it entirely. +func TestMCPOneShotSemantics(t *testing.T) { + // /mcp fs, fetch lands → focus applied once. A later plain /tools + // fetch must NOT re-apply the focus. + m := wired(t) + runSlash(t, m, "/mcp fs") + if r := m.toolSelected(); r == nil || r.text != "fs" { + t.Fatalf("focus not applied: %+v", r) + } + m.Update(exec(m.openTools())) // plain /tools re-fetch + if r := m.toolSelected(); r != nil && r.kind == "mcp" { + t.Fatalf("stale focus re-applied on a plain /tools fetch: %+v", r) + } + + // /mcp fs then a tab switch before the fetch lands: the late tools + // result is dropped by the cross-tab guard AND the jump is disarmed, + // so a subsequent Tools load must not apply the stale focus. + m2 := wired(t) + runSlash(t, m2, "/mcp fs") + m2.Update(exec(m2.switchDrawerTab(panelMemory))) // tab switch disarms + m2.Update(exec(m2.openTools())) + if r := m2.toolSelected(); r != nil && r.kind == "mcp" { + t.Fatalf("disarmed jump re-applied after tab switch: %+v", r) + } +} + +// TestMCPErrorConsumesFocus verifies a failed tools fetch consumes the +// one-shot focus instead of leaking it into the next successful load. +func TestMCPErrorConsumesFocus(t *testing.T) { + m := wired(t) + runSlash(t, m, "/mcp fs") + m.handleMgmtMsg(mgmtMsg{tab: panelTools, err: errors.New("boom")}) + if m.mcpJump || m.mcpFocus != "" { + t.Fatalf("error left the jump armed: jump=%v focus=%q", m.mcpJump, m.mcpFocus) + } + m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 1, tls: []client.Tool{{Name: "shell", Enabled: true}}}) + if r := m.toolSelected(); r != nil && r.kind == "mcp" { + t.Fatalf("stale focus applied after an error recovery load: %+v", r) + } +} + +// TestMCPUnknownNotClobbered verifies the not-found note survives the +// zero-MCP-server note on the same frame. +func TestMCPUnknownNotClobbered(t *testing.T) { + m := wired(t) + m.panel = panelTools + m.mcpJump, m.mcpFocus = true, "gone" + m.handleMgmtMsg(mgmtMsg{tab: panelTools, mcpN: 0, tls: []client.Tool{{Name: "shell", Enabled: true}}}) + if !strings.Contains(m.panelMsg, "no MCP server named") { + t.Errorf("not-found note clobbered: %q", m.panelMsg) + } +} + // TestMCPZeroServers verifies the zero-server empty state: the Tools tab // still opens (it has native tools) with a "no MCP servers configured" note. func TestMCPZeroServers(t *testing.T) { diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index e10e2da..f444d45 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -187,6 +187,10 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { return } if msg.err != nil { + // A failed fetch consumes the one-shot /mcp focus like a + // successful one — it must not leak into the next Tools load. + m.mcpJump = false + m.mcpFocus = "" m.panelMsg = "error: " + msg.err.Error() return } @@ -222,20 +226,21 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } // One-shot /mcp focus: land on the first MCP row (bare) or the // named server (case-insensitive). Consumed exactly once. + notFound := false if m.mcpJump { m.mcpJump = false - if focus := m.mcpFocus; focus != "" { - m.mcpFocus = "" - found := false + focus := m.mcpFocus + m.mcpFocus = "" + if focus != "" { for i, r := range m.toolRows { if r.kind == "mcp" && strings.EqualFold(r.id, focus) { m.panelSel = i - found = true break } } - if !found { - m.panelMsg = "no MCP server named '" + focus + "' — /mcp lists all" + if r := m.toolSelected(); r == nil || r.kind != "mcp" || !strings.EqualFold(r.id, focus) { + m.panelMsg = fmt.Sprintf("no MCP server named %q — /mcp lists all", focus) + notFound = true } } else { for i, r := range m.toolRows { @@ -246,7 +251,7 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } } } - if msg.mcpN == 0 && len(m.toolRows) > 0 { + if msg.mcpN == 0 && len(m.toolRows) > 0 && !notFound { m.panelMsg = fmt.Sprintf("%d built-ins · no MCP servers configured", len(msg.tls)) } case panelConfig: