Skip to content
Open
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
2 changes: 2 additions & 0 deletions bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
(require '[eca-cli.chat-test])
(require '[eca-cli.commands-test])
(require '[eca-cli.lifecycle-test])
(require '[eca-cli.mcp-test])
(require '[eca-cli.protocol-test])
(require '[eca-cli.view-test])
(require '[eca-cli.view.blocks-test])
Expand All @@ -23,6 +24,7 @@
(clojure.test/run-tests 'eca-cli.chat-test
'eca-cli.commands-test
'eca-cli.lifecycle-test
'eca-cli.mcp-test
'eca-cli.protocol-test
'eca-cli.view-test
'eca-cli.view.blocks-test
Expand Down
2 changes: 2 additions & 0 deletions src/eca_cli/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
[eca-cli.sessions :as sessions]
[eca-cli.picker :as picker]
[eca-cli.login :as login]
[eca-cli.mcp :as mcp]
[eca-cli.view :as view]))

(declare command-registry)
Expand Down Expand Up @@ -67,6 +68,7 @@
(def command-registry
{"/model" {:doc "Open model picker" :handler cmd-open-model-picker}
"/agent" {:doc "Open agent picker" :handler cmd-open-agent-picker}
"/mcp" {:doc "View MCP server status" :handler mcp/cmd-open-mcp-panel}
"/new" {:doc "Start a fresh chat" :handler cmd-new-chat}
"/sessions" {:doc "Browse and resume previous chats" :handler cmd-list-sessions}
"/clear" {:doc "Clear chat display (local only)" :handler cmd-clear-chat}
Expand Down
181 changes: 181 additions & 0 deletions src/eca_cli/mcp.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
(ns eca-cli.mcp
"MCP server status: `tool/serverUpdated` handler, /mcp panel, status-bar slot,
and `mcp/connectServer` dispatch. Owns the `:mcps` state slice — a map of
server-name → server info kept in sync with the ECA server. No back-references
to eca-cli.state.

:mcps shape — keyed by name (string):
{:name string
:status string ; running | starting | stopped | failed | disabled | requires-auth
:disabled boolean
:hasAuth boolean
:command string? :args [string]? :url string?
:tools [tool]? :prompts [prompt]? :resources [resource]?}"
(:require [charm.components.list :as cl]
[charm.components.text-input :as ti]
[charm.message :as msg]
[charm.program :as program]
[clojure.string :as str]
[eca-cli.protocol :as protocol]))

;; --- /mcp command + panel ---

(defn picker-open?
"True when state is in the `:picking` mode with the `:mcp` picker kind. Used
by mcp.clj (handler refresh, panel render) and state.clj (Enter dispatch) to
share one definition of \"MCP picker is the active overlay\"."
[state]
(and (= :picking (:mode state))
(= :mcp (get-in state [:picker :kind]))))

(defn- panel-list [mcps]
(mapv val (sort-by key mcps)))

(defn- apply-query [entries query]
(if (str/blank? query)
entries
(let [q (str/lower-case query)]
(filterv #(str/includes? (str/lower-case (:name %)) q) entries))))

(defn- refresh-mcp-picker
"Rebuild the open :mcp picker from `:mcps`, re-applying `:query` and preserving
selection by server name when possible. Returns updated state. Caller must
guard that the picker is open + `:kind :mcp`."
[state]
(let [{:keys [list filtered query]} (:picker state)
prev-idx (cl/selected-index list)
prev-name (when (and (some? prev-idx) (< prev-idx (count filtered)))
(:name (nth filtered prev-idx)))
all (panel-list (:mcps state))
filtered' (apply-query all query)
labels (mapv :name filtered')
new-idx (or (when prev-name
(some (fn [[i n]] (when (= n prev-name) i))
(map-indexed vector labels)))
0)
new-list (-> list (cl/set-items labels) (cl/select new-idx))]
(-> state
(assoc-in [:picker :all] all)
(assoc-in [:picker :filtered] filtered')
(assoc-in [:picker :list] new-list))))

;; --- ECA notification handler ---

(defn handle-tool-server-updated
"Handles `tool/serverUpdated`. Non-MCP `:type` values are ignored. MCP servers
are upserted into `:mcps` keyed by `:name` — subsequent updates replace prior
entries rather than appending. If the `/mcp` picker is currently open the
picker's `:all`/`:filtered`/`:list` are refreshed in lockstep, preserving the
current query and selection (by server name) where possible."
[state params]
(if (= "mcp" (:type params))
(let [name (:name params)
entry (-> params
(dissoc :type)
(update :tools #(or % []))
(update :prompts #(or % []))
(update :resources #(or % [])))
state' (assoc-in state [:mcps name] entry)
state' (if (picker-open? state')
(refresh-mcp-picker state')
state')]
[state' nil])
[state nil]))

(defn cmd-open-mcp-panel
"Opens the `/mcp` panel. Empty `:mcps` shows a system message instead."
[state]
(if (empty? (:mcps state))
[(-> state
(update :items conj {:type :system :text "⚠ No MCP servers configured"}))
nil]
(let [entries (panel-list (:mcps state))]
[(-> state
(assoc :mode :picking
:picker {:kind :mcp
:list (cl/item-list (mapv :name entries) :height 8)
:all entries
:filtered entries
:query ""})
(update :input ti/reset))
nil])))

;; --- Render ---

(defn- status-emoji [status]
(case status
"running" "🟢"
"starting" "🟡"
"failed" "🔴"
"stopped" "⚪"
"disabled" "⚫"
"requires-auth" "🟠"
"⚪"))

(defn- render-row [{:keys [name status tools]}]
(let [base (str (status-emoji status) " " name " · " (count tools) " tools · " status)]
(cond-> base
(= "requires-auth" status) (str " [connect]")
(= "failed" status) (str " (check ~/.cache/eca/eca-cli.log)"))))

(defn render-mcp-panel-lines
"Renders panel rows: one line per MCP server, sorted alphabetically by name.
When the `/mcp` picker is open, rows come from the picker's `:filtered`
entries so the display stays in lockstep with Enter's selection target."
[state]
(let [entries (if (picker-open? state)
(get-in state [:picker :filtered])
(panel-list (:mcps state)))]
(mapv render-row entries)))

;; --- Status-bar fragment ---

(defn status-bar-fragment
"Returns the status-bar MCP slot string, or nil when no MCPs are known.
Wide (>=120 cols): `MCPs: n/m ✓` (or `⚠` when any non-running). Narrow: `M:n/m`."
[state width]
(let [mcps (:mcps state)]
(when (seq mcps)
(let [total (count mcps)
running (count (filter #(= "running" (:status (val %))) mcps))
wide? (>= width 120)
sentinel (if (= running total) "✓" "⚠")]
(if wide?
(str "MCPs: " running "/" total " " sentinel)
(str "M:" running "/" total))))))

;; --- connect-server dispatch ---

(defn connect-server!
"Sends `mcp/connectServer` notification for the given server name. Pure cmd
builder — returns [state cmd]."
[state name]
[state
(program/cmd
(fn []
(protocol/mcp-connect-server! (:server state) name)
nil))])

;; --- :picking :kind :mcp key dispatch ---

(defn- selected-entry [state]
(let [{:keys [list filtered]} (:picker state)
idx (cl/selected-index list)]
(when (and (some? idx) (< idx (count filtered)))
(nth filtered idx))))

(defn handle-key
"Dispatches keys for the :mcp picker. Enter on requires-auth → connect;
otherwise no-op. Escape and filter behaviours are handled by picker.clj."
[state msg]
(cond
(and (msg/key-press? msg) (msg/key-match? msg :enter))
(if-let [entry (selected-entry state)]
(if (= "requires-auth" (:status entry))
(let [[s' cmd] (connect-server! state (:name entry))]
[(-> s' (assoc :mode :ready) (dissoc :picker) (update :input ti/focus)) cmd])
[state nil])
[state nil])

:else
[state nil]))
3 changes: 3 additions & 0 deletions src/eca_cli/protocol.clj
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
(defn selected-agent-changed! [srv agent]
(send-notification! srv "chat/selectedAgentChanged" {:agent agent}))

(defn mcp-connect-server! [srv name]
(send-notification! srv "mcp/connectServer" {:name name}))

(defn list-chats! [srv callback]
(send-request! srv "chat/list" {:limit 20} callback))

Expand Down
11 changes: 11 additions & 0 deletions src/eca_cli/state.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
[eca-cli.chat :as chat]
[eca-cli.picker :as picker]
[eca-cli.login :as login]
[eca-cli.mcp :as mcp]
[eca-cli.commands :as commands]))

;; Expose last-known state for nREPL inspection
Expand Down Expand Up @@ -74,6 +75,9 @@
"chat/cleared"
(chat/handle-chat-cleared state notification)

"tool/serverUpdated"
(mcp/handle-tool-server-updated state (:params notification))

[state nil]))

(defn- handle-eca-tick [state msgs]
Expand Down Expand Up @@ -136,6 +140,7 @@
:scroll-offset 0
:width 80
:height 24
:mcps {}
:model nil
:usage nil})

Expand Down Expand Up @@ -245,6 +250,12 @@
cmd-name)
[state nil]))

;; MCP-picker Enter arm — connect on requires-auth rows; everything else
;; falls through to the generic picker dispatch (filter, navigation, Esc).
(and (msg/key-press? msg) (msg/key-match? msg :enter)
(mcp/picker-open? state))
(mcp/handle-key state msg)

(= :picking (:mode state)) (picker/handle-key state msg)
(#{:ready :chatting} (:mode state)) (chat/handle-key state msg)

Expand Down
18 changes: 12 additions & 6 deletions src/eca_cli/view.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
(:require [clojure.string :as str]
[charm.components.list :as cl]
[charm.components.text-input :as ti]
[eca-cli.mcp :as mcp]
[eca-cli.view.blocks :as blocks]))

(defn divider [width]
Expand Down Expand Up @@ -65,11 +66,15 @@
(str "🚧 " summary "\n[y] approve [Y] always [n] reject"))))

(defn- render-picker [state]
(let [{:keys [kind query list]} (:picker state)
label (case kind :model "model" :agent "agent" :session "chat" :command "command" "item")]
(str "Select " label " (type to filter): " query "\n"
(divider (:width state)) "\n"
(cl/list-view list))))
(let [{:keys [kind query list]} (:picker state)]
(if (= :mcp kind)
(str "MCP servers\n"
(divider (:width state)) "\n"
(str/join "\n" (mcp/render-mcp-panel-lines state)))
Comment on lines +69 to +73
(let [label (case kind :model "model" :agent "agent" :session "chat" :command "command" "item")]
(str "Select " label " (type to filter): " query "\n"
(divider (:width state)) "\n"
(cl/list-view list))))))

(defn render-status-bar [state]
(let [workspace (-> (get-in state [:opts :workspace] ".")
Expand All @@ -78,6 +83,7 @@
model (or (:selected-model state) (:model state) "…")
agent (:selected-agent state)
variant (:selected-variant state)
mcps-frag (mcp/status-bar-fragment state (or (:width state) 80))
usage (:usage state)
tokens (some-> usage :sessionTokens (str "tok"))
cost (some-> usage :sessionCost)
Expand All @@ -91,7 +97,7 @@
(str "\"" (subs t 0 24) "…\"")
(str "\"" t "\""))))
trust (if (:trust state) "TRUST" "SAFE")]
(str/join " " (remove nil? [workspace loading model agent variant tokens cost ctx-pct chat-title trust]))))
(str/join " " (remove nil? [workspace loading model agent variant mcps-frag tokens cost ctx-pct chat-title trust]))))

(defn render-login [state]
(let [{:keys [provider action field-idx]} (:login state)
Expand Down
Loading