Add MCP status button to workspace toolbar with single-tab connection control (#9930)

*  Add MCP connection badge to the workspace toolbar

*  Add MCP status button with single-tab connection control

* ♻️ Extract component for MCP indicator in the toolbar

* ♻️ Some improvements

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
This commit is contained in:
Juan de la Cruz 2026-06-15 11:29:25 +02:00 committed by GitHub
parent 6a1c3348f3
commit ddeaf3ce2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 279 additions and 146 deletions

View File

@ -504,8 +504,7 @@
(rx/of (dwu/append-undo entry stack-undo?)))
(rx/empty))))))
(rx/take-until stoper-s))
(rx/of (mcp/notify-other-tabs-disconnect)))))
(rx/take-until stoper-s)))))
ptk/EffectEvent
(effect [_ _ _]

View File

@ -10,13 +10,10 @@
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.register :as preg]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -39,6 +36,14 @@
(defonce interval-sub (atom nil))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/force-disconnect {})
(ptk/data-event ::connect)))))
(defn finalize-workspace?
[event]
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
@ -72,45 +77,6 @@
(rx/dispose! @interval-sub)
(reset! interval-sub nil)))
(declare manage-mcp-notification)
(defn handle-pong
[{:keys [id data]}]
(ptk/reify ::handle-pong
ptk/UpdateEvent
(update [_ state]
(let [mcp-state (get state :mcp)]
(cond
(= "connected" (:connection-status data))
(update state :mcp assoc :connected-tab id)
(and (= "disconnected" (:connection-status data))
(= id (:connected-tab mcp-state)))
(update state :mcp dissoc :connected-tab)
:else
state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)))))
;; This event will arrive when a new workspace is open in another tab
(defn handle-ping
[]
(ptk/reify ::handle-ping
ptk/WatchEvent
(watch [_ state _]
(let [conn-status (get-in state [:mcp :connection-status])]
(rx/of (mbc/event :mcp/pong {:connection-status conn-status}))))))
(defn notify-other-tabs-disconnect
[]
(ptk/reify ::notify-other-tabs-disconnect
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/pong {:connection-status "disconnected"})))))
;; This event will arrive when the mcp is enabled in the dashboard
(defn update-mcp-status
[value]
@ -121,12 +87,10 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (manage-mcp-notification))
(case value
true (rx/of (ptk/data-event ::connect))
false (rx/of (ptk/data-event ::disconnect))
nil)))))
(case value
true (rx/of (connect-mcp))
false (rx/of (ptk/data-event ::disconnect))
nil))))
(defn update-mcp-connection-status
[value]
@ -137,20 +101,13 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)
(mbc/event :mcp/pong {:connection-status value})))))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/force-disconect {})
(ptk/data-event ::connect)))))
;; Only one MCP plugin instance may be active across browser tabs.
;; When this tab becomes connected, tell every other tab to
;; disconnect (which also stops their reconnect watcher). Otherwise
;; several tabs stay connected at once and the MCP server reports
;; "multiple instances connected" and the agent fails.
(when (= "connected" value)
(rx/of (mbc/event :mcp/force-disconnect {}))))))
;; This event will arrive when the user selects disconnect on the menu
;; or there is a broadcast message for disconnection
@ -166,33 +123,6 @@
(effect [_ _ _]
(stop-reconnect-watcher!))))
(defn- manage-mcp-notification
[]
(ptk/reify ::manage-mcp-notification
ptk/WatchEvent
(watch [_ state _]
(let [mcp-state (get state :mcp)
mcp-enabled? (-> state :profile :props :mcp-enabled)
current-tab-id (get state :session-id)
connected-tab-id (get mcp-state :connected-tab)]
(if mcp-enabled?
(if (= connected-tab-id current-tab-id)
(rx/of (ntf/hide))
(rx/of (ntf/dialog
{:content (tr "notifications.mcp.active-in-another-tab")
:cancel {:label (tr "labels.dismiss")
:callback #(st/emit! (ntf/hide)
(ev/event {::ev/name "dismiss-mcp-tab-switch"
::ev/origin "workspace-notification"}))}
:accept {:label (tr "labels.switch")
:callback #(st/emit! (connect-mcp)
(ev/event {::ev/name "confirm-mcp-tab-switch"
::ev/origin "workspace-notification"}))}})))
(rx/of (ntf/hide)))))))
(defn init-mcp
[stream]
;; Wait for plugins runtime to be initialized before starting the MCP plugin.
@ -240,7 +170,7 @@
(ptk/reify ::init
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state) :active true))
(update state :mcp assoc :active true))
ptk/WatchEvent
(watch [_ state stream]
@ -255,22 +185,8 @@
(rx/merge
(init-mcp stream)
(rx/of (mbc/event :mcp/ping {}))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/ping))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-ping))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/pong))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-pong))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/force-disconect))
(rx/filter (mbc/type? :mcp/force-disconnect))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map deref)
@ -280,9 +196,9 @@
(->> mbc/stream
(rx/filter (mbc/type? :mcp/enable))
(rx/mapcat (fn [_]
;; NOTE: we don't need an explicit
;; connect because the plugin has
;; auto-connect
;; Re-init so the force-disconnect
;; listener is set up now that MCP
;; is enabled.
(rx/of (update-mcp-status true)
(init)))))
@ -290,7 +206,6 @@
(rx/filter (mbc/type? :mcp/disable))
(rx/mapcat (fn [_]
(rx/of (update-mcp-status false)
(init)
(user-disconnect-mcp))))))
(rx/take-until stoper-s))))))

View File

@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cph]
[app.common.time :as ct]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.common.types.tokens-lib :as ctob]
@ -155,6 +156,14 @@
(def mcp
(l/derived :mcp st/state))
(def mcp-key-expired?
(l/derived (fn [state]
(when-let [expires-at (some->> (:access-tokens state)
(some #(when (= (:type %) "mcp") %))
:expires-at)]
(> (ct/now) expires-at)))
st/state))
(def workspace-drawing
(l/derived :workspace-drawing st/state))

View File

@ -406,18 +406,16 @@
(mf/defc mcp-server-section*
{::mf/private true}
[]
(let [tokens (mf/deref refs/access-tokens)
profile (mf/deref refs/profile)
(let [tokens (mf/deref refs/access-tokens)
profile (mf/deref refs/profile)
mcp-key-expired? (mf/deref refs/mcp-key-expired?)
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-token (:token mcp-key "")
mcp-url (dm/str cf/mcp-server-url "?userToken=" mcp-token)
mcp-enabled? (true? (-> profile :props :mcp-enabled))
expires-at (:expires-at mcp-key)
expired? (and (some? expires-at) (> (ct/now) expires-at))
show-enabled? (and mcp-enabled? (false? expired?))
show-enabled? (and mcp-enabled? (false? mcp-key-expired?))
tooltip-id
(mf/use-id)
@ -494,7 +492,7 @@
(tr "integrations.mcp-server.status")]
[:div {:class (stl/css :mcp-server-block)}
(when expired?
(when mcp-key-expired?
[:> notification-pill* {:level :error
:type :context}
[:div {:class (stl/css :mcp-server-notification)}
@ -517,7 +515,7 @@
(when (and (false? mcp-enabled?) (nil? mcp-key))
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-generate-mcp-key}])
(when (true? expired?)
(when (true? mcp-key-expired?)
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-regenerate-mcp-key}])]]]

View File

@ -10,7 +10,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
@ -791,20 +790,15 @@
[{:keys [on-close]}]
(let [plugins? (features/active-feature? @st/state "plugins/runtime")
profile (mf/deref refs/profile)
mcp (mf/deref refs/mcp)
tokens (mf/deref refs/access-tokens)
expires-at (some->> tokens
(some #(when (= (:type %) "mcp") %))
:expires-at)
expired? (and (some? expires-at) (> (ct/now) expires-at))
profile (mf/deref refs/profile)
mcp (mf/deref refs/mcp)
mcp-key-expired? (mf/deref refs/mcp-key-expired?)
mcp-enabled? (true? (-> profile :props :mcp-enabled))
mcp-connection (get mcp :connection-status)
mcp-connected? (= mcp-connection "connected")
show-enabled? (and mcp-enabled? (false? expired?))
show-enabled? (and mcp-enabled? (false? mcp-key-expired?))
on-nav-to-integrations
(mf/use-fn
@ -843,7 +837,7 @@
:pos-6 plugins?)
:on-close on-close}
(when (and show-enabled? (not expired?))
(when (and show-enabled? (not mcp-key-expired?))
[:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin"
:class (stl/css :base-menu-item :submenu-item)
:on-click on-toggle-mcp-plugin
@ -865,7 +859,6 @@
(mf/defc menu*
[{:keys [layout file]}]
(let [profile (mf/deref refs/profile)
mcp (mf/deref refs/mcp)
show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
@ -1062,11 +1055,8 @@
:class (stl/css :item-arrow)}]])
(when (contains? cf/flags :mcp)
(let [tokens (mf/deref refs/access-tokens)
expires-at (some->> tokens
(some #(when (= (:type %) "mcp") %))
:expires-at)
expired? (and (some? expires-at) (> (ct/now) expires-at))
(let [mcp (mf/deref refs/mcp)
mcp-key-expired? (mf/deref refs/mcp-key-expired?)
mcp-enabled? (true? (-> profile :props :mcp-enabled))
mcp-connection (get mcp :connection-status)
@ -1075,7 +1065,7 @@
active? (and mcp-enabled? mcp-connected?)
failed? (or (and mcp-enabled? mcp-error?)
(true? expired?))]
(true? mcp-key-expired?))]
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item)
:on-click on-menu-click

View File

@ -8,15 +8,18 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.geom.point :as gpt]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.shortcuts :as sc]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.context :as ctx]
[app.main.ui.icons :as deprecated-icon]
@ -26,6 +29,61 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
(mf/defc mcp-indicator*
[]
(let [profile (mf/deref refs/profile)
mcp (mf/deref refs/mcp)
mcp-key-expired? (mf/deref refs/mcp-key-expired?)
mcp-enabled? (true? (-> profile :props :mcp-enabled))
mcp-connected? (= "connected" (:connection-status mcp))
show-indicator? (and mcp-enabled? (false? mcp-key-expired?))
mcp-menu-open* (mf/use-state false)
mcp-menu-open? (deref mcp-menu-open*)
toggle-mcp-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! mcp-menu-open* not)))
close-mcp-menu
(mf/use-fn
#(reset! mcp-menu-open* false))
connect-mcp
(mf/use-fn
#(st/emit! (mcp/connect-mcp)
(ev/event {::ev/name "connect-mcp-plugin"
::ev/origin "workspace:toolbar"})))]
(when show-indicator?
[:li
[:button
{:title (tr "workspace.toolbar.mcp")
:aria-label (tr "workspace.toolbar.mcp")
:class (stl/css-case :main-toolbar-options-button true
:mcp-button true
:selected mcp-menu-open?)
:on-click toggle-mcp-menu
:data-tool "mcp"
:data-testid "mcp-btn"}
[:span {:class (stl/css-case :mcp-status-dot true
:connected mcp-connected?)}]
[:span {:class (stl/css-case :mcp-button-label true
:connected mcp-connected?)}
(tr "workspace.toolbar.mcp")]]
[:> dropdown-menu* {:show mcp-menu-open?
:on-close close-mcp-menu
:class (stl/css :mcp-menu)}
(if mcp-connected?
[:li {:class (stl/css :mcp-menu-info)
:role "presentation"}
(tr "workspace.toolbar.mcp-connected")]
[:> dropdown-menu-item* {:class (stl/css :mcp-menu-item)
:on-click connect-mcp}
(tr "workspace.toolbar.mcp-connect-here")])]])))
(mf/defc image-upload*
{::mf/wrap [mf/memo]}
[]
@ -221,12 +279,13 @@
{:title "Debugging tool"
:class (stl/css-case :main-toolbar-options-button true :selected (contains? layout :debug-panel))
:on-click toggle-debug-panel}
deprecated-icon/bug]])]]
deprecated-icon/bug]])
(when (contains? cf/flags :mcp)
[:> mcp-indicator*])]]
[:button {:title (tr "workspace.toolbar.toggle-toolbar")
:aria-label (tr "workspace.toolbar.toggle-toolbar")
:class (stl/css :toolbar-handler)
:on-click toggle-toolbar}
[:div {:class (stl/css :toolbar-handler-btn)}]]])))

View File

@ -5,6 +5,9 @@
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/typography.scss" as t;
.main-toolbar {
cursor: initial;
@ -83,6 +86,102 @@
}
}
.mcp-button {
display: flex;
align-items: center;
gap: var(--sp-xs);
width: fit-content;
margin-inline-start: var(--sp-xs);
padding-inline: var(--sp-s);
}
.mcp-button-label {
@include t.use-typography("body-small");
&.connected {
color: var(--color-accent-primary);
}
}
.mcp-status-dot {
// Connection indicator placed before the label, vertically centered:
// a muted gray when disconnected, the primary accent when connected.
position: relative;
flex-shrink: 0;
inline-size: px2rem(6);
block-size: px2rem(6);
border-radius: 50%;
background-color: var(--color-background-disabled);
&.connected {
background-color: var(--color-accent-primary);
}
// One-shot "blob" ripple that confirms the moment the tab becomes
// connected (e.g. after using "Switch here"). Triggered purely by the
// `.connected` class appearing, in sync with the color change.
&.connected::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--color-accent-primary);
opacity: 0;
animation: mcp-status-blob 0.5s ease-out;
}
}
@keyframes mcp-status-blob {
from {
opacity: 0.5;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.5);
}
}
.mcp-menu {
@include deprecated.menu-shadow;
position: absolute;
inset-block-start: calc(100% + var(--sp-xs));
inset-inline-start: var(--sp-xs);
z-index: var(--z-index-dropdown);
margin: 0;
padding: var(--sp-xs);
list-style: none;
border: $b-1 solid var(--panel-border-color);
border-radius: $br-8;
background-color: var(--menu-background-color);
}
// Non-interactive informational text inside the menu (no hover, not focusable).
.mcp-menu-info {
@include t.use-typography("body-small");
padding: var(--sp-s) var(--sp-m);
color: var(--color-foreground-secondary);
white-space: nowrap;
}
.mcp-menu-item {
@include t.use-typography("body-small");
display: flex;
align-items: center;
padding: var(--sp-s) var(--sp-m);
border-radius: $br-8;
color: var(--menu-foreground-color);
white-space: nowrap;
&:hover {
background-color: var(--menu-background-color-hover);
}
}
.toolbar-handler {
@include deprecated.flex-center;
@include deprecated.button-style;

View File

@ -0,0 +1,50 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.workspace-mcp-test
(:require
[app.main.data.workspace.mcp :as mcp]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest test-set-mcp-active
(t/testing "sets :active to true"
(let [state {:mcp {:active false}}
result (ptk/update (mcp/set-mcp-active true) state)]
(t/is (true? (get-in result [:mcp :active])))))
(t/testing "sets :active to false"
(let [state {:mcp {:active true}}
result (ptk/update (mcp/set-mcp-active false) state)]
(t/is (false? (get-in result [:mcp :active]))))))
(t/deftest test-update-mcp-status
(t/testing "enables MCP in profile props"
(let [state {:profile {:props {:mcp-enabled false}}}
result (ptk/update (mcp/update-mcp-status true) state)]
(t/is (true? (get-in result [:profile :props :mcp-enabled])))))
(t/testing "disables MCP in profile props"
(let [state {:profile {:props {:mcp-enabled true}}}
result (ptk/update (mcp/update-mcp-status false) state)]
(t/is (false? (get-in result [:profile :props :mcp-enabled]))))))
(t/deftest test-update-mcp-connection-status
(t/testing "sets connection status to connected"
(let [state {:mcp {:connection-status "disconnected"}}
result (ptk/update (mcp/update-mcp-connection-status "connected") state)]
(t/is (= "connected" (get-in result [:mcp :connection-status])))))
(t/testing "sets connection status to disconnected"
(let [state {:mcp {:connection-status "connected"}}
result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)]
(t/is (= "disconnected" (get-in result [:mcp :connection-status]))))))
(t/deftest test-init-sets-active
(t/testing "init sets :mcp :active to true"
(let [state {:mcp {:active false}}
result (ptk/update (mcp/init) state)]
(t/is (true? (get-in result [:mcp :active]))))))

View File

@ -7,6 +7,7 @@
[frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-texts-test]
[frontend-tests.data.workspace-thumbnails-test]
@ -55,6 +56,7 @@
'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-texts-test
'frontend-tests.data.workspace-thumbnails-test

View File

@ -4043,9 +4043,6 @@ msgstr "Invitation sent successfully"
msgid "notifications.invitation-link-copied"
msgstr "Invitation link copied"
msgid "notifications.mcp.active-in-another-tab"
msgstr "MCP is active in another tab. Switch here?"
msgid "notifications.mcp.active-in-this-tab"
msgstr "MCP is now active in this tab."
@ -8986,6 +8983,15 @@ msgstr "Create board. Click and drag to define its size. (%s)"
msgid "workspace.toolbar.image"
msgstr "Image (%s)"
msgid "workspace.toolbar.mcp"
msgstr "MCP"
msgid "workspace.toolbar.mcp-connected"
msgstr "MCP connected"
msgid "workspace.toolbar.mcp-connect-here"
msgstr "Connect here"
#: src/app/main/ui/workspace/top_toolbar.cljs:143
msgid "workspace.toolbar.move"
msgstr "Move (%s)"

View File

@ -3952,9 +3952,6 @@ msgstr "Invitación enviada con éxito"
msgid "notifications.invitation-link-copied"
msgstr "Enlace de invitacion copiado"
msgid "notifications.mcp.active-in-another-tab"
msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?"
msgid "notifications.mcp.active-in-this-tab"
msgstr "MCP está ahora activo en esta pestaña."
@ -8708,6 +8705,15 @@ msgstr "Crear tablero. Click y arrastrar para definir el tamaño. (%s)"
msgid "workspace.toolbar.image"
msgstr "Imagen (%s)"
msgid "workspace.toolbar.mcp"
msgstr "MCP"
msgid "workspace.toolbar.mcp-connected"
msgstr "MCP conectado"
msgid "workspace.toolbar.mcp-connect-here"
msgstr "Conectar aquí"
#: src/app/main/ui/workspace/top_toolbar.cljs:143
msgid "workspace.toolbar.move"
msgstr "Mover (%s)"