From 196e47fa93a2f417a52b26e6952b7cee0d891bca Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 18 Jun 2026 18:08:40 +0200 Subject: [PATCH 1/4] :rewind: Backport MCP related changes from develop (#10315) * :recycle: Add mcp integration state management refactor (#10226) * :recycle: Add mcp integration state management refactor * :bug: Fix access tokens do not appear * :recycle: Refactor some names * :recycle: Refactor token deletion --------- Co-authored-by: Luis de Dios * :bug: Fix stale MCP token data after create/regenerate (#10280) Fix the root cause in profile.cljs: remove the optimistic conj from access-token-created and instead chain a fetch-access-tokens after the create-access-token API call succeeds. This ensures all callers get a fresh, server-consistent token list automatically. Suggested-by: niwinz Signed-off-by: kapilvus Co-authored-by: kapilvus * :sparkles: Remove non-recoverable mcp key warning from regenerated modal (#10298) --------- Signed-off-by: kapilvus Co-authored-by: Luis de Dios Co-authored-by: kapil971390 Co-authored-by: kapilvus --- backend/src/app/rpc/commands/access_token.clj | 23 +- .../backend_tests/rpc_access_tokens_test.clj | 80 ++++- frontend/src/app/main/data/profile.cljs | 24 +- frontend/src/app/main/data/workspace.cljs | 14 +- frontend/src/app/main/data/workspace/mcp.cljs | 178 ++++++----- frontend/src/app/main/refs.cljs | 9 - .../app/main/ui/settings/integrations.cljs | 290 ++++++++++-------- .../src/app/main/ui/workspace/main_menu.cljs | 58 ++-- .../app/main/ui/workspace/top_toolbar.cljs | 34 +- .../data/workspace_mcp_test.cljs | 117 +++++-- 10 files changed, 544 insertions(+), 283 deletions(-) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 331e2bdb36..0aee2c42a8 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -22,6 +22,12 @@ [row] (dissoc row :perms)) +(def ^:private sql:clean-old-mcp-tokens + "DELETE FROM access_token + WHERE profile_id = ? + AND id != ? + AND type = 'mcp'") + (defn create-access-token [{:keys [::db/conn] :as cfg} profile-id name expiration type] (let [token-id (uuid/next) @@ -42,6 +48,13 @@ :updated-at created-at :expires-at expires-at :perms (db/create-array conn "text" [])})] + + ;; If the created token is of mcp type, we should proceed to + ;; delete all other mcp tokens on the table for the current + ;; profile. + (when (= type "mcp") + (db/exec! conn [sql:clean-old-mcp-tokens profile-id (:id token)])) + (decode-row token))) (defn repl:create-access-token @@ -85,14 +98,20 @@ (->> (db/query pool :access-token {:profile-id profile-id} {:order-by [[:expires-at :asc] [:created-at :asc]] - :columns [:id :name :perms :type :created-at :updated-at :expires-at]}) - (mapv decode-row))) + :columns [:id :name :perms :type :created-at :updated-at :expires-at :token]}) + (map decode-row) + (map (fn [{:keys [type] :as row}] + (if (not= type "mcp") + (dissoc row :token) + row))) + (vec))) (def ^:private schema:get-current-mcp-token [:map {:title "get-current-mcp-token"}]) (sv/defmethod ::get-current-mcp-token {::doc/added "2.15" + ::doc/deprecated true ::sm/params schema:get-current-mcp-token} [{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}] (->> (db/query pool :access-token diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index fb08dc65fa..5554633b72 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -120,5 +120,83 @@ ::rpc/profile-id (:id prof)})] ;; (th/print-result! result) (t/is (nil? error)) - (t/is (string? (:token result))))))) + (t/is (string? (:token result))))) + (t/testing "get-access-tokens returns :token for MCP tokens but not for regular tokens" + (let [;; Create a regular token + regular-out (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :name "regular token" + :perms ["get-profile"]}) + regular-token (:result regular-out) + + ;; Create an MCP token + mcp-out (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "mcp token" + :perms []}) + mcp-token (:result mcp-out) + + ;; Fetch all tokens + {:keys [error result]} + (th/command! {::th/type :get-access-tokens + ::rpc/profile-id (:id prof)})] + + (t/is (nil? error)) + + ;; Find our tokens in the result + (let [regular (some #(when (= (:id %) (:id regular-token)) %) result) + mcp (some #(when (= (:id %) (:id mcp-token)) %) result)] + + ;; Regular tokens should NOT have :token + (t/is (some? regular)) + (t/is (not (contains? regular :token))) + + ;; MCP tokens SHOULD have :token + (t/is (some? mcp)) + (t/is (contains? mcp :token)) + (t/is (string? (:token mcp)))))) + + (t/testing "creating MCP token removes previous MCP tokens" + (let [;; Create first MCP token + first-out (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "first mcp" + :perms []}) + first-mcp (:result first-out) + + ;; Create second MCP token + second-out (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "second mcp" + :perms []}) + second-mcp (:result second-out) + + ;; Create third MCP token + third-out (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "third mcp" + :perms []}) + third-mcp (:result third-out) + + ;; Fetch all tokens + {:keys [error result]} + (th/command! {::th/type :get-access-tokens + ::rpc/profile-id (:id prof)})] + + (t/is (nil? error)) + + ;; Count MCP tokens - should only be 1 (the third one) + (let [mcp-tokens (filter #(= (:type %) "mcp") result)] + (t/is (= 1 (count mcp-tokens))) + (t/is (= (:id third-mcp) (:id (first mcp-tokens))))) + + ;; Verify the first and second MCP tokens are gone + (let [all-ids (set (map :id result))] + (t/is (not (contains? all-ids (:id first-mcp)))) + (t/is (not (contains? all-ids (:id second-mcp)))) + (t/is (contains? all-ids (:id third-mcp)))))))) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index c9eaf0e802..e37dc08e45 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -473,6 +473,9 @@ (defn access-tokens-fetched [access-tokens] (ptk/reify ::access-tokens-fetched + IDeref + (-deref [_] access-tokens) + ptk/UpdateEvent (update [_ state] (assoc state :access-tokens access-tokens)))) @@ -487,7 +490,7 @@ ;; --- EVENT: create-access-token -(defn access-token-created +(defn- access-token-created [access-token] (ptk/reify ::access-token-created ptk/UpdateEvent @@ -495,24 +498,35 @@ (assoc state :access-token-created access-token)))) (defn create-access-token - [{:keys [] :as params}] + [params] (ptk/reify ::create-access-token ptk/WatchEvent (watch [_ _ _] (let [{:keys [on-success on-error] :or {on-success identity - on-error rx/throw}} (meta params)] + on-error rx/throw}} + (meta params)] + (->> (rp/cmd! :create-access-token params) - (rx/map access-token-created) (rx/tap on-success) + (rx/mapcat (fn [token] + (rx/of (access-token-created token) + (fetch-access-tokens)))) (rx/catch on-error)))))) ;; --- EVENT: delete-access-token (defn delete-access-token [{:keys [id] :as params}] - (assert (uuid? id)) + (assert (uuid? id) "expect valid token id") + (ptk/reify ::delete-access-token + ptk/UpdateEvent + (update [_ state] + (update state :access-tokens + (fn [tokens] + (into [] (remove #(= id (:id %))) tokens)))) + ptk/WatchEvent (watch [_ _ _] (let [{:keys [on-success on-error] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 429ccf95ef..cbcd5bb81b 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -73,6 +73,7 @@ [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] [app.render-wasm.wasm :as wasm-state] @@ -253,8 +254,12 @@ (rx/merge (rx/of (dp/check-open-plugin) (fdf/fix-deleted-fonts-for-local-library file-id)) - (when (contains? cf/flags :mcp) - (rx/of (mcp/init))))))) + (if (contains? cf/flags :mcp) + ;; We wait the plugin runtime to be ready before launch the + ;; mcp initialization + (->> (rx/from (preg/wait-for-runtime)) + (rx/map (fn [_] (mcp/init)))) + (rx/empty)))))) (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] @@ -376,10 +381,7 @@ (rx/of (ntf/hide) (dcmt/retrieve-comment-threads file-id) (dcmt/fetch-profiles) - (df/fetch-fonts team-id)) - - (when (contains? cf/flags :mcp) - (rx/of (du/fetch-access-tokens)))) + (df/fetch-fonts team-id))) ;; Once the essential data is fetched, lets proceed to ;; fetch teh file bunldle diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index f2c3c069db..55d15e4852 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -6,12 +6,14 @@ (ns app.main.data.workspace.mcp (:require + [app.common.data :as d] [app.common.logging :as log] + [app.common.time :as ct] [app.common.uri :as u] [app.config :as cf] [app.main.broadcast :as mbc] [app.main.data.plugins :as dp] - [app.main.repo :as rp] + [app.main.data.profile :as du] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] @@ -22,7 +24,8 @@ (log/set-level! :info) -(def ^:private default-manifest +(def ^:private + default-manifest {:code "plugin.js" :name "Penpot MCP Plugin" :version 2 @@ -34,7 +37,8 @@ "comment:read" "comment:write" "content:write" "content:read"}}) -(defonce interval-sub (atom nil)) +(defonce interval-sub + (atom nil)) (defn connect-mcp [] @@ -44,20 +48,8 @@ (rx/of (mbc/event :mcp/force-disconnect {}) (ptk/data-event ::connect))))) -(defn finalize-workspace? - [event] - (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) - -(defn set-mcp-active - [value] - (ptk/reify ::set-mcp-active - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:mcp :active] value)))) - -(defn start-reconnect-watcher! +(defn- start-reconnect-watcher [] - (st/emit! (set-mcp-active true)) (when (nil? @interval-sub) (reset! interval-sub @@ -72,7 +64,6 @@ (defn stop-reconnect-watcher! [] - (st/emit! (set-mcp-active false)) (when @interval-sub (rx/dispose! @interval-sub) (reset! interval-sub nil))) @@ -83,7 +74,9 @@ (ptk/reify ::update-mcp-status ptk/UpdateEvent (update [_ state] - (update-in state [:profile :props] assoc :mcp-enabled value)) + (-> state + (update :mcp assoc :enabled value) + (update-in [:profile :props] assoc :mcp-enabled value))) ptk/WatchEvent (watch [_ _ _] @@ -123,76 +116,120 @@ (effect [_ _ _] (stop-reconnect-watcher!)))) -(defn init-mcp - [stream] - ;; Wait for plugins runtime to be initialized before starting the MCP plugin. - ;; This ensures global.ɵloadPlugin is available when start-plugin! is called. - (->> (rx/from (preg/wait-for-runtime)) - (rx/mapcat - (fn [_] - (->> (rp/cmd! :get-current-mcp-token) - (rx/tap - (fn [{:keys [token]}] - (when token - (dp/start-plugin! - (assoc default-manifest - :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) - :host (str (u/join cf/public-uri "plugins/mcp/"))) +(defn- init-mcp-plugin + "Waits the plugin runtime and initializes the bundled MCP plugin." + [{:keys [token]}] + (ptk/reify ::init-mcp-plugin + ptk/EffectEvent + (effect [_ _ stream] + (let [manifest (-> default-manifest + (assoc :url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))) + (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) - ;; API extension for MCP server - #js {:mcp - #js - {:getToken (constantly token) - :getServerUrl #(str cf/mcp-ws-uri) - :setMcpStatus - (fn [status] - (when (= status "connected") - (start-reconnect-watcher!)) - (st/emit! (update-mcp-connection-status status)) - (log/info :hint "MCP STATUS" :status status)) + stopper-s (rx/merge + (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::stop-mcp-plugin) stream)) - :on - (fn [event cb] - (when-let [event - (case event - "disconnect" ::disconnect - "connect" ::connect - nil)] + extension #js {:getToken (constantly token) + :getServerUrl #(str cf/mcp-ws-uri) + :setMcpStatus + (fn [status] + (when (= status "connected") + (start-reconnect-watcher)) + (st/emit! (update-mcp-connection-status status)) + (log/info :hint "MCP STATUS" :status status)) - (let [stopper (rx/filter finalize-workspace? stream)] - (->> stream - (rx/filter (ptk/type? event)) - (rx/take-until stopper) - (rx/subs! #(cb))))))}}))))))))) + :on + (fn [event cb] + (when-let [event + (case event + "disconnect" ::disconnect + "connect" ::connect + nil)] + + (->> stream + (rx/filter (ptk/type? event)) + (rx/take-until stopper-s) + (rx/subs! (fn [_] (cb))))))}] + + (dp/start-plugin! manifest #js {:mcp extension}))))) + +(defn- stop-mcp-plugin + [] + (ptk/reify ::stop-mcp-plugin + ptk/EffectEvent + (effect [_ _ _] + (dp/close-plugin! default-manifest)))) + +(defn- init-mcp-state + [access-tokens] + (let [token (d/seek #(= "mcp" (:type %)) access-tokens) + valid? (and token + (as-> (get token :expires-at) expires-at + (or (nil? expires-at) + (> expires-at (ct/now)))))] + + (ptk/reify ::init-mcp-state + IDeref + (-deref [_] + (when valid? token)) + + ptk/UpdateEvent + (update [_ state] + (if token + (update state :mcp (fn [state] + (-> state + (assoc :token (:token token)) + (assoc :token-id (:id token)) + (assoc :token-valid valid?)))) + state))))) (defn init + "Initialize MCP runtime. This event expects plugin runtime initialized." [] (ptk/reify ::init ptk/UpdateEvent (update [_ state] - (update state :mcp assoc :active true)) + (let [profile (get state :profile) + mcp-enabled? (-> profile :props :mcp-enabled boolean)] + (update state :mcp assoc :enabled mcp-enabled?))) ptk/WatchEvent (watch [_ state stream] - (let [stoper-s (rx/merge + (let [stopper-s (rx/merge (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) (rx/filter (ptk/type? ::init) stream)) + session-id (get state :session-id) - enabled? (-> state :profile :props :mcp-enabled)] + mcp-state (get state :mcp)] (->> (rx/merge - (if enabled? - (rx/merge - (init-mcp stream) + (rx/of (du/fetch-access-tokens)) - (->> mbc/stream - (rx/filter (mbc/type? :mcp/force-disconnect)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map deref) - (rx/map (fn [] (user-disconnect-mcp))))) + ;; Wait until access tokens are initialized and are + ;; setup on the state + (->> stream + (rx/filter (ptk/type? ::du/access-tokens-fetched)) + (rx/map deref) + (rx/take 1) + (rx/map init-mcp-state)) + + (if (:enabled mcp-state) + (->> stream + (rx/filter (ptk/type? ::init-mcp-state)) + (rx/take 1) + (rx/map deref) + (rx/filter some?) + (rx/map init-mcp-plugin)) (rx/empty)) + (->> mbc/stream + (rx/filter (mbc/type? :mcp/force-disconnect)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map deref) + (rx/map (fn [] (user-disconnect-mcp)))) + (->> mbc/stream (rx/filter (mbc/type? :mcp/enable)) (rx/mapcat (fn [_] @@ -206,6 +243,7 @@ (rx/filter (mbc/type? :mcp/disable)) (rx/mapcat (fn [_] (rx/of (update-mcp-status false) - (user-disconnect-mcp)))))) + (user-disconnect-mcp) + (stop-mcp-plugin)))))) - (rx/take-until stoper-s)))))) + (rx/take-until stopper-s)))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index b87b0eff43..cc77c0a14d 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -10,7 +10,6 @@ [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] @@ -156,14 +155,6 @@ (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)) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index e7d51438bc..403893ea68 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.settings.integrations (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.time :as ct] @@ -43,7 +44,7 @@ [:name [::sm/text {:max 250}]] [:expiration-date [::sm/text {:max 250}]]]) -(def ^:private schema:form-mcp-key +(def ^:private schema:form-mcp-token [:map [:expiration-date [::sm/text {:max 250}]]]) @@ -51,7 +52,7 @@ {:name "" :expiration-date "never"}) -(def form-initial-data-mcp-key +(def form-initial-data-mcp-token {:expiration-date "never"}) (mf/defc input-copy* @@ -70,7 +71,7 @@ (mf/defc token-created* {::mf/private true} - [{:keys [title mcp-key?]}] + [{:keys [title is-mcp]}] (let [token-created (mf/deref refs/access-token-created) on-copy-to-clipboard @@ -81,7 +82,7 @@ (clipboard/to-clipboard (:token token-created)) (st/emit! (ntf/show {:level :info :type :toast - :content (if mcp-key? + :content (if is-mcp (tr "integrations.notification.success.mcp-key-copied") (tr "integrations.notification.success.token-copied")) :timeout notification-timeout}))))] @@ -92,14 +93,13 @@ :class (stl/css :color-primary)} title] - [:> notification-pill* {:level :info - :type :context} - [:> text* {:as "div" - :typography t/body-small - :class (stl/css :color-primary)} - (if mcp-key? - (tr "integrations.mcp-key.info.non-recuperable") - (tr "integrations.token.info.non-recuperable"))]] + (when-not is-mcp + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (tr "integrations.token.info.non-recuperable")]]) [:div {:class (stl/css :modal-content)} [:> input-copy* {:value (:token token-created "") @@ -109,14 +109,14 @@ :typography t/body-small :class (stl/css :color-secondary)} (if (:expires-at token-created) - (if mcp-key? + (if is-mcp (tr "integrations.mcp-key.will-expire" (ct/format-inst (:expires-at token-created) "PPP")) (tr "integrations.token.will-expire" (ct/format-inst (:expires-at token-created) "PPP"))) - (if mcp-key? + (if is-mcp (tr "integrations.mcp-key.will-not-expire") (tr "integrations.token.will-not-expire")))]] - (when mcp-key? + (when is-mcp [:div {:class (stl/css :modal-content)} [:> text* {:as "div" :typography t/body-small @@ -125,15 +125,15 @@ [:textarea {:class (stl/css :textarea) :wrap "off" :rows 7 - :read-only true} - (dm/str - "{\n" - " \"mcpServers\": {\n" - " \"penpot\": {\n" - " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" - " }\n" - " }" - "\n}")]]) + :read-only true + :value (dm/str + "{\n" + " \"mcpServers\": {\n" + " \"penpot\": {\n" + " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" + " }\n" + " }" + "\n}")}]]) [:div {:class (stl/css :modal-footer)} [:> button* {:variant "secondary" @@ -142,13 +142,13 @@ (mf/defc create-token* {::mf/private true} - [{:keys [title info mcp-key? on-created]}] + [{:keys [title info is-mcp on-created]}] (let [form (fm/use-form - :initial (if mcp-key? - form-initial-data-mcp-key + :initial (if is-mcp + form-initial-data-mcp-token form-initial-data-access-token) - :schema (if mcp-key? - schema:form-mcp-key + :schema (if is-mcp + schema:form-mcp-token schema:form-access-token)) on-error @@ -158,12 +158,14 @@ on-success (mf/use-fn - #(st/emit! (du/fetch-access-tokens) - (ntf/success (tr "integrations.notification.success.created")) - (on-created))) + (mf/deps on-created) + (fn [] + (when (fn? on-created) + (on-created)))) on-submit (mf/use-fn + (mf/deps is-mcp) (fn [form] (let [cdata (:clean-data @form) mdata {:on-success (partial on-success form) @@ -171,9 +173,12 @@ expiration (:expiration-date cdata) params (cond-> {:name (:name cdata) :perms (:perms cdata)} - (not= "never" expiration) (assoc :expiration expiration) - (true? mcp-key?) (assoc :type "mcp" - :name "MCP key"))] + (not= "never" expiration) + (assoc :expiration expiration) + + (true? is-mcp) + (assoc :type "mcp" :name "MCP key"))] + (st/emit! (du/create-access-token (with-meta params mdata))))))] [:> fc/form* {:form form @@ -193,7 +198,7 @@ :class (stl/css :color-primary)} info]]) - (if mcp-key? + (if is-mcp [:div {:class (stl/css :modal-content)} [:> text* {:as "div" :typography t/body-medium @@ -233,7 +238,8 @@ {::mf/register modal/components ::mf/register-as :create-access-token} [] - (let [created? (mf/use-state false) + (let [created? + (mf/use-state false) on-close (mf/use-fn @@ -243,7 +249,9 @@ on-created (mf/use-fn - #(reset! created? true))] + (fn [] + (reset! created? true) + (st/emit! (ntf/success (tr "integrations.notification.success.created")))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} @@ -258,9 +266,9 @@ [:> create-token* {:title (tr "integrations.create-access-token.title") :on-created on-created}])]])) -(mf/defc generate-mcp-key-modal +(mf/defc generate-mcp-token-modal {::mf/register modal/components - ::mf/register-as :generate-mcp-key} + ::mf/register-as :generate-mcp-token} [] (let [created? (mf/use-state false) @@ -273,7 +281,10 @@ on-created (mf/use-fn (fn [] + ;; NOTE: Analytics events use "key" terminology for historical reasons; + ;; these names are immutable to avoid breaking analytics dashboards. (st/emit! (du/update-profile-props {:mcp-enabled true}) + (ntf/success (tr "integrations.notification.success.created")) (ev/event {::ev/name "generate-mcp-key" ::ev/origin "integrations"}) (ev/event {::ev/name "enable-mcp" @@ -292,20 +303,20 @@ (if @created? [:> token-created* {:title (tr "integrations.generate-mcp-key.title.created") - :mcp-key? true}] + :is-mcp true}] [:> create-token* {:title (tr "integrations.generate-mcp-key.title") - :mcp-key? true + :is-mcp true :on-created on-created}])]])) -(mf/defc regenerate-mcp-key-modal - {::mf/register modal/components - ::mf/register-as :regenerate-mcp-key} - [] - (let [created? (mf/use-state false) +(defn- mcp-access-token? + [atoken] + (= "mcp" (:type atoken))) - tokens (mf/deref refs/access-tokens) - mcp-key (some #(when (= (:type %) "mcp") %) tokens) - mcp-key-id (:id mcp-key) +(mf/defc regenerate-mcp-token-modal + {::mf/register modal/components + ::mf/register-as :regenerate-mcp-token} + [] + (let [created? (mf/use-state false) on-close (mf/use-fn @@ -316,8 +327,9 @@ on-created (mf/use-fn (fn [] - (st/emit! (du/delete-access-token {:id mcp-key-id}) - (du/update-profile-props {:mcp-enabled true}) + ;; NOTE: Analytics event uses "key" terminology for historical reasons. + (st/emit! (du/update-profile-props {:mcp-enabled true}) + (ntf/success (tr "integrations.notification.success.created")) (ev/event {::ev/name "regenerate-mcp-key" ::ev/origin "integrations"}) (mbc/event :mcp/enable {})) @@ -333,16 +345,16 @@ (if @created? [:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created") - :mcp-key? true}] + :is-mcp true}] [:> create-token* {:title (tr "integrations.regenerate-mcp-key.title") :info (tr "integrations.regenerate-mcp-key.info") - :mcp-key? true + :is-mcp true :on-created on-created}])]])) (mf/defc token-item* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [name expires-at on-delete]}] + [{:keys [id name expires-at on-delete]}] (let [expires-txt (some-> expires-at (ct/format-inst "PPP")) expired? (and (some? expires-at) (> (ct/now) expires-at)) @@ -357,18 +369,23 @@ (mf/use-fn #(reset! menu-open* (not menu-open?))) + handle-delete + (mf/use-fn + (mf/deps on-delete id) + #(on-delete id)) + handle-open-confirm-modal (mf/use-fn - (mf/deps on-delete) + (mf/deps handle-delete) (fn [] (st/emit! (modal/show {:type :confirm :title (tr "integrations.delete-token.title") :message (tr "integrations.delete-token.message") :accept-label (tr "integrations.delete-token.accept") - :on-accept on-delete})))) + :on-accept handle-delete})))) options - (mf/with-memo [on-delete] + (mf/with-memo [handle-delete] [{:name (tr "labels.delete") :id "token-delete" :handler handle-open-confirm-modal}])] @@ -403,22 +420,31 @@ :left -138 :options options}]]])) +(defn valid-mcp-token? + "Given access tokens list, return if it contains a valid (not expired) mcp key" + [{:keys [expires-at] :as token}] + (if token + (or (nil? expires-at) + (> expires-at (ct/now))) + false)) + (mf/defc mcp-server-section* {::mf/private true} - [] - (let [tokens (mf/deref refs/access-tokens) - profile (mf/deref refs/profile) - mcp-key-expired? (mf/deref refs/mcp-key-expired?) + [{:keys [access-tokens]}] + (let [profile (mf/deref refs/profile) - 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)) + token (mf/with-memo [access-tokens] + (when-let [token (d/seek mcp-access-token? access-tokens)] + (assoc token :is-valid (valid-mcp-token? token)))) - show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) + token-valid? (get token :is-valid) + token-str (get token :token) + enabled? (-> profile :props :mcp-enabled boolean) - tooltip-id - (mf/use-id) + url (dm/str cf/mcp-server-url "?userToken=" token-str) + + show-enabled? (and enabled? token-valid?) + tooltip-id (mf/use-id) handle-mcp-change (mf/use-fn @@ -437,19 +463,18 @@ (mbc/event :mcp/enable {}) (mbc/event :mcp/disable {}))))) - handle-generate-mcp-key + handle-open-modal-generate (mf/use-fn - #(st/emit! (modal/show {:type :generate-mcp-key}))) + #(st/emit! (modal/show {:type :generate-mcp-token}))) - handle-regenerate-mcp-key + handle-open-modal-regenerate (mf/use-fn - #(st/emit! (modal/show {:type :regenerate-mcp-key}))) + #(st/emit! (modal/show {:type :regenerate-mcp-token}))) handle-delete (mf/use-fn - (mf/deps mcp-key) - (fn [] - (let [params {:id (:id mcp-key)} + (fn [id] + (let [params {:id id} mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] (st/emit! (du/delete-access-token (with-meta params mdata)) (du/update-profile-props {:mcp-enabled false}) @@ -457,10 +482,10 @@ on-copy-to-clipboard (mf/use-fn - (mf/deps mcp-url) + (mf/deps url) (fn [event] (dom/prevent-default event) - (clipboard/to-clipboard mcp-url) + (clipboard/to-clipboard url) (st/emit! (ntf/show {:level :info :type :toast :content (tr "integrations.notification.success.copied-link") @@ -492,7 +517,7 @@ (tr "integrations.mcp-server.status")] [:div {:class (stl/css :mcp-server-block)} - (when mcp-key-expired? + (when (and (some? token) (not token-valid?)) [:> notification-pill* {:level :error :type :context} [:div {:class (stl/css :mcp-server-notification)} @@ -512,14 +537,16 @@ (tr "integrations.mcp-server.status.disabled")) :default-checked show-enabled? :on-change handle-mcp-change}] - (when (and (false? mcp-enabled?) (nil? mcp-key)) - [:div {:class (stl/css :mcp-server-switch-cover) - :on-click handle-generate-mcp-key}]) - (when (true? mcp-key-expired?) - [:div {:class (stl/css :mcp-server-switch-cover) - :on-click handle-regenerate-mcp-key}])]]] - (when (some? mcp-key) + (when-not token + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-open-modal-generate}]) + + (when (and token (not token-valid?)) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-open-modal-regenerate}])]]] + + (when (some? token) [:div {:class (stl/css :mcp-server-key)} [:> text* {:as "h3" :typography t/headline-small @@ -530,7 +557,7 @@ [:div {:class (stl/css :mcp-server-regenerate)} [:> button* {:variant "primary" :class (stl/css :fit-content) - :on-click handle-regenerate-mcp-key} + :on-click handle-open-modal-regenerate} (tr "integrations.mcp-server.mcp-keys.regenerate")] [:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip") :id tooltip-id} @@ -538,21 +565,24 @@ :class (stl/css :color-secondary)}]]] [:div {:class (stl/css :list)} - [:> token-item* {:key (:id mcp-key) - :name (:name mcp-key) - :expires-at (:expires-at mcp-key) + [:> token-item* {:key (:id token) + :id (:id token) + :name (:name token) + :expires-at (:expires-at token) :on-delete handle-delete}]]]]) [:> notification-pill* {:level :default :type :context} [:div {:class (stl/css :mcp-server-notification)} - [:> text* {:as "div" - :typography t/body-medium - :class (stl/css :color-secondary)} - (tr "integrations.mcp-server.mcp-keys.info")] + (when token + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.mcp-keys.info")]) - [:> input-copy* {:value mcp-url - :on-copy-to-clipboard on-copy-to-clipboard}] + (when token + [:> input-copy* {:value url + :on-copy-to-clipboard on-copy-to-clipboard}]) [:> text* {:as "div" :typography t/body-medium @@ -561,12 +591,15 @@ :target "_blank" :rel "noopener noreferrer" :class (stl/css :mcp-server-notification-link)} - (tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]])) + (tr "integrations.mcp-server.mcp-keys.help") + [:> icon* {:icon-id i/open-link}]]]]]])) (mf/defc access-tokens-section* {::mf/private true} - [] - (let [tokens (mf/deref refs/access-tokens) + [{:keys [access-tokens]}] + (let [access-tokens + (mf/with-memo [access-tokens] + (not-empty (filter #(nil? (:type %)) access-tokens))) handle-click (mf/use-fn @@ -595,40 +628,49 @@ :on-click handle-click} (tr "integrations.access-tokens.create")] - (if (empty? tokens) + (if access-tokens + [:div {:class (stl/css :list)} + (for [{:keys [id] :as token} access-tokens] + [:> token-item* {:key (dm/str id) + :id id + :name (:name token) + :expires-at (:expires-at token) + :on-delete handle-delete}])] + [:div {:class (stl/css :frame)} [:> text* {:as "div" :typography t/body-medium :class (stl/css :color-secondary :text-center)} [:div (tr "integrations.access-tokens.empty.no-access-tokens")] - [:div (tr "integrations.access-tokens.empty.add-one")]]] - - [:div {:class (stl/css :list)} - (for [token tokens] - (when (nil? (:type token)) - [:> token-item* {:key (:id token) - :name (:name token) - :expires-at (:expires-at token) - :on-delete (partial handle-delete (:id token))}]))])])) + [:div (tr "integrations.access-tokens.empty.add-one")]]])])) (mf/defc integrations-page* [] - (mf/with-effect [] - (dom/set-html-title (tr "title.settings.integrations")) - (st/emit! (du/fetch-access-tokens))) + (let [access-tokens (mf/deref refs/access-tokens) + props (mf/props {:access-tokens access-tokens}) - [:div {:class (stl/css :integrations)} - [:> heading* {:level 1 - :typography t/title-large - :class (stl/css :color-primary)} - (tr "integrations.title")] + access-tokens-enabled? + (contains? cf/flags :access-tokens) - (when (contains? cf/flags :mcp) - [:> mcp-server-section*]) + mcp-enabled? + (contains? cf/flags :mcp)] - (when (and (contains? cf/flags :mcp) - (contains? cf/flags :access-tokens)) - [:hr {:class (stl/css :separator)}]) + (mf/with-effect [] + (dom/set-html-title (tr "title.settings.integrations")) + (st/emit! (du/fetch-access-tokens))) - (when (contains? cf/flags :access-tokens) - [:> access-tokens-section*])]) + [:div {:class (stl/css :integrations)} + [:> heading* {:level 1 + :typography t/title-large + :class (stl/css :color-primary)} + (tr "integrations.title")] + + (when ^boolean mcp-enabled? + [:> mcp-server-section* props]) + + (when (and ^boolean mcp-enabled? + ^boolean access-tokens-enabled?) + [:hr {:class (stl/css :separator)}]) + + (when ^boolean access-tokens-enabled? + [:> access-tokens-section* props])])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 53d51a7970..8b87b01d5a 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -107,7 +107,7 @@ plugins? (features/active-feature? @st/state "plugins/runtime") - mcp? + mcp-enabled? (contains? cf/flags :mcp) show-shortcuts @@ -137,9 +137,9 @@ :on-close on-close :class (stl/css-case :base-menu true :sub-menu true - :pos-final-5 (not (or plugins? mcp?)) - :pos-final-6 (not= plugins? mcp?) - :pos-final-7 (and plugins? mcp?))} + :pos-final-5 (not (or plugins? mcp-enabled?)) + :pos-final-6 (not= plugins? mcp-enabled?) + :pos-final-7 (and plugins? mcp-enabled?))} [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-helpc-center :on-key-down (fn [event] @@ -787,18 +787,15 @@ (mf/defc mcp-menu* {::mf/private true} - [{:keys [on-close]}] - (let [plugins? (features/active-feature? @st/state "plugins/runtime") + [{:keys [on-close mcp]}] + (let [plugins-enabled? (features/use-feature "plugins/runtime") + has-valid-token? (get mcp :token-valid) + enabled? (get mcp :enabled) - profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) - mcp-key-expired? (mf/deref refs/mcp-key-expired?) + conn-status (get mcp :connection-status) + connected? (= conn-status "connected") - mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connection (get mcp :connection-status) - mcp-connected? (= mcp-connection "connected") - - show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) + show-enabled? (and enabled? has-valid-token?) on-nav-to-integrations (mf/use-fn @@ -815,8 +812,9 @@ on-toggle-mcp-plugin (mf/use-fn + (mf/deps connected?) (fn [] - (if mcp-connected? + (if connected? (st/emit! (mcp/user-disconnect-mcp) (ev/event {::ev/name "disconnect-mcp-plugin" ::ev/origin "workspace:menu"})) @@ -833,17 +831,18 @@ [:> dropdown-menu* {:show true :class (stl/css-case :base-menu true :sub-menu true - :pos-5 (not plugins?) - :pos-6 plugins?) + :pos-5 (not plugins-enabled?) + :pos-6 plugins-enabled?) :on-close on-close} - (when (and show-enabled? (not mcp-key-expired?)) + + (when (and show-enabled? has-valid-token?) [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" :class (stl/css :base-menu-item :submenu-item) :on-click on-toggle-mcp-plugin :on-key-down on-toggle-mcp-plugin-key-down} [:span {:class (stl/css :item-name)} - (if mcp-connected? + (if connected? (tr "workspace.header.menu.mcp.plugin.status.disconnect") (tr "workspace.header.menu.mcp.plugin.status.connect"))]]) @@ -864,6 +863,7 @@ show-menu? (deref show-menu*) selected-sub-menu* (mf/use-state nil) selected-sub-menu (deref selected-sub-menu*) + mcp (mf/deref refs/mcp) toggle-menu (mf/use-fn @@ -1055,17 +1055,17 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [mcp (mf/deref refs/mcp) - mcp-key-expired? (mf/deref refs/mcp-key-expired?) + (let [enabled? (get mcp :enabled) + conn-status (get mcp :connection-status) + has-valid-token? (get mcp :token-valid) - mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connection (get mcp :connection-status) - mcp-connected? (= mcp-connection "connected") - mcp-error? (= mcp-connection "error") + connected? (= conn-status "connected") + error? (= conn-status "error") - active? (and mcp-enabled? mcp-connected?) - failed? (or (and mcp-enabled? mcp-error?) - (true? mcp-key-expired?))] + + active? (and enabled? connected?) + failed? (or (and enabled? error?) + (not has-valid-token?))] [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click @@ -1140,7 +1140,7 @@ :on-close close-sub-menu}] :mcp - [:> mcp-menu* {:on-close close-sub-menu}] + [:> mcp-menu* {:on-close close-sub-menu :mcp mcp}] :help-info [:> help-info-menu* {:layout layout diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 6b36ae2664..0743f85633 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -31,26 +31,28 @@ (mf/defc mcp-indicator* [] - (let [profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) - mcp-key-expired? (mf/deref refs/mcp-key-expired?) + (let [mcp (mf/deref refs/mcp) - mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connected? (= "connected" (:connection-status mcp)) - show-indicator? (and mcp-enabled? (false? mcp-key-expired?)) + conn-status (get mcp :connection-status) + has-valid-token? (get mcp :token-valid) - mcp-menu-open* (mf/use-state false) - mcp-menu-open? (deref mcp-menu-open*) + enabled? (get mcp :enabled) - toggle-mcp-menu + mcp-connected? (= "connected" conn-status) + show-indicator? (and enabled? has-valid-token?) + + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (swap! mcp-menu-open* not))) + (swap! menu-open* not))) - close-mcp-menu + close-menu (mf/use-fn - #(reset! mcp-menu-open* false)) + #(reset! menu-open* false)) connect-mcp (mf/use-fn @@ -64,8 +66,8 @@ :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 + :selected menu-open?) + :on-click toggle-menu :data-tool "mcp" :data-testid "mcp-btn"} [:span {:class (stl/css-case :mcp-status-dot true @@ -73,8 +75,8 @@ [: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 + [:> dropdown-menu* {:show menu-open? + :on-close close-menu :class (stl/css :mcp-menu)} (if mcp-connected? [:li {:class (stl/css :mcp-menu-info) diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index e81dab6780..7f16894984 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -6,31 +6,25 @@ (ns frontend-tests.data.workspace-mcp-test (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.main.data.profile :as du] [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}}} + (t/testing "enables MCP in profile props and mcp state" + (let [state {:profile {:props {:mcp-enabled false}} :mcp {}} result (ptk/update (mcp/update-mcp-status true) state)] - (t/is (true? (get-in result [:profile :props :mcp-enabled]))))) + (t/is (true? (get-in result [:profile :props :mcp-enabled]))) + (t/is (true? (get-in result [:mcp :enabled]))))) - (t/testing "disables MCP in profile props" - (let [state {:profile {:props {:mcp-enabled true}}} + (t/testing "disables MCP in profile props and mcp state" + (let [state {:profile {:props {:mcp-enabled true}} :mcp {:enabled true}} result (ptk/update (mcp/update-mcp-status false) state)] - (t/is (false? (get-in result [:profile :props :mcp-enabled])))))) + (t/is (false? (get-in result [:profile :props :mcp-enabled]))) + (t/is (false? (get-in result [:mcp :enabled])))))) (t/deftest test-update-mcp-connection-status (t/testing "sets connection status to connected" @@ -43,8 +37,89 @@ 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}} +(t/deftest test-init-sets-enabled + (t/testing "init sets :mcp :enabled to true when profile has mcp-enabled" + (let [state {:mcp {} :profile {:props {:mcp-enabled true}}} result (ptk/update (mcp/init) state)] - (t/is (true? (get-in result [:mcp :active])))))) + (t/is (true? (get-in result [:mcp :enabled]))))) + + (t/testing "init sets :mcp :enabled to false when profile has mcp-disabled" + (let [state {:mcp {:enabled true} :profile {:props {:mcp-enabled false}}} + result (ptk/update (mcp/init) state)] + (t/is (false? (get-in result [:mcp :enabled]))))) + + (t/testing "init sets :mcp :enabled to false when profile has no mcp-enabled prop" + (let [state {:mcp {:enabled true} :profile {:props {}}} + result (ptk/update (mcp/init) state)] + (t/is (false? (get-in result [:mcp :enabled])))))) + +(t/deftest test-init-mcp-state + (let [token-id (uuid/next)] + (t/testing "with valid MCP token (future expiration)" + (let [future-date (ct/plus (ct/now) #js {:hours 24}) + tokens [{:id token-id :type "mcp" :token "abc123" :expires-at future-date}] + event (#'mcp/init-mcp-state tokens) + state {:mcp {}} + result (ptk/update event state)] + (t/is (= "abc123" (get-in result [:mcp :token]))) + (t/is (= token-id (get-in result [:mcp :token-id]))) + (t/is (true? (get-in result [:mcp :token-valid]))) + ;; deref should return the token when valid + (t/is (some? @event)))) + + (t/testing "with MCP token with no expiration" + (let [tokens [{:id token-id :type "mcp" :token "abc123" :expires-at nil}] + event (#'mcp/init-mcp-state tokens) + state {:mcp {}} + result (ptk/update event state)] + (t/is (= "abc123" (get-in result [:mcp :token]))) + (t/is (true? (get-in result [:mcp :token-valid]))) + (t/is (some? @event)))) + + (t/testing "with expired MCP token" + (let [past-date (ct/minus (ct/now) #js {:hours 24}) + tokens [{:id token-id :type "mcp" :token "abc123" :expires-at past-date}] + event (#'mcp/init-mcp-state tokens) + state {:mcp {}} + result (ptk/update event state)] + (t/is (= "abc123" (get-in result [:mcp :token]))) + (t/is (false? (get-in result [:mcp :token-valid]))) + ;; deref should return nil when token is expired + (t/is (nil? @event)))) + + (t/testing "with no MCP token" + (let [tokens [{:id token-id :type nil :token "regular-token"}] + event (#'mcp/init-mcp-state tokens) + state {:mcp {:existing "data"}} + result (ptk/update event state)] + ;; state should be unchanged when no MCP token exists + (t/is (= {:mcp {:existing "data"}} result)) + (t/is (nil? @event)))) + + (t/testing "with mixed tokens finds MCP token" + (let [regular-id (uuid/next) + mcp-id (uuid/next) + tokens [{:id regular-id :type nil :token "regular"} + {:id mcp-id :type "mcp" :token "mcp-token" :expires-at nil}] + event (#'mcp/init-mcp-state tokens) + result (ptk/update event {:mcp {}})] + (t/is (= "mcp-token" (get-in result [:mcp :token]))) + (t/is (= mcp-id (get-in result [:mcp :token-id]))))))) + +(t/deftest test-delete-access-token-optimistic-update + (let [token-1 {:id (uuid/next) :name "token-1"} + token-2 {:id (uuid/next) :name "token-2"} + token-3 {:id (uuid/next) :name "token-3"}] + + (t/testing "removes token from :access-tokens optimistically" + (let [state {:access-tokens [token-1 token-2 token-3]} + event (du/delete-access-token {:id (:id token-2)}) + result (ptk/update event state)] + (t/is (= 2 (count (:access-tokens result)))) + (t/is (= [token-1 token-3] (:access-tokens result))))) + + (t/testing "state unchanged when token id not found" + (let [state {:access-tokens [token-1 token-2]} + event (du/delete-access-token {:id (uuid/next)}) + result (ptk/update event state)] + (t/is (= 2 (count (:access-tokens result)))))))) From d3bec9586085a99cd043f9f1d47e29080931e97c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Jun 2026 17:50:59 +0200 Subject: [PATCH 2/4] :bug: Allow pasting comma-separated emails in multi-input (#10186) The multi-input component did not handle paste events for comma-separated values. When users pasted emails like 'qa@example.com, test@example.com', the entire string was inserted as-is, triggering validation errors. The on-key-down handler already split text on commas/spaces when typing, but paste events bypassed this logic. Added an on-paste handler that: - Detects if pasted text contains commas or whitespace - Splits the text by commas and/or whitespace - Validates each part individually - Adds valid items to the items list - Prevents default paste behavior - Resets input state after processing Signed-off-by: Andrey Antukh --- .../src/app/main/ui/components/forms.cljs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 099b5e43dd..0d4a5fc55f 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -557,6 +557,32 @@ (dom/stop-propagation event) (swap! items (fn [items] (if (c/empty? items) items (pop items))))))))) + on-paste + (mf/use-fn + (fn [event] + (let [paste-data (-> event .-clipboardData (.getData "text"))] + (when (and (string? paste-data) + (re-find #"[,\s]" paste-data)) + (dom/prevent-default event) + (dom/stop-propagation event) + + ;; Mark as touched + (swap! form assoc-in [:touched input-name] true) + + ;; Split pasted text by commas and/or whitespace, add each valid part + (let [parts (->> (str/split paste-data #",|\s+") + (map str/trim) + (remove str/empty?))] + (doseq [part parts] + (when (valid-item-fn part) + (swap! items conj-dedup {:text part + :valid true + :caution (caution-item-fn part)}))) + + ;; Reset input value and mark as untouched after successful paste + (reset! value "") + (swap! form assoc-in [:touched input-name] false)))))) + on-blur (mf/use-fn (fn [_] @@ -590,6 +616,7 @@ :on-focus on-focus :on-blur on-blur :on-key-down on-key-down + :on-paste on-paste :value @value :on-change on-change :placeholder (when empty? label)}] From d8894abcbc5e34935364b01e606a94a4787a8d40 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 22 Jun 2026 09:04:01 +0200 Subject: [PATCH 3/4] :paperclip: Update changelog --- CHANGES.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 6d4c63d06f..d8a0d1c40b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,23 @@ # CHANGELOG +## 2.16.1 + +### :sparkles: New features & Enhancements + +- Batch multiple thumbnail deletions into a single RPC call [#9944](https://github.com/penpot/penpot/issues/9944) (PR: [#9943](https://github.com/penpot/penpot/pull/9943)) +- Add WebSocket proxy configuration for MCP in Nginx example (by @lancatlin) [#10153](https://github.com/penpot/penpot/issues/10153) (PR: [#10152](https://github.com/penpot/penpot/pull/10152)) +- Add tenant prefix to MCP Redis channel names for multi-environment isolation [#10277](https://github.com/penpot/penpot/issues/10277) (PR: [#10276](https://github.com/penpot/penpot/pull/10276)) +- Show MCP key on Integrations page and remove non-recoverable warning from modal [#10290](https://github.com/penpot/penpot/issues/10290) (PR: [#10298](https://github.com/penpot/penpot/pull/10298)) + +### :bug: Bugs fixed + +- Improve performance of multiple nested flex layouts with text content [#10095](https://github.com/penpot/penpot/issues/10095) +- Fix race condition between MCP initialization and plugin runtime [#10138](https://github.com/penpot/penpot/issues/10138) (PR: [#10137](https://github.com/penpot/penpot/pull/10137)) +- Filter ignorable React removeChild errors from browser extensions in error boundary [#10146](https://github.com/penpot/penpot/issues/10146) (PR: [#10145](https://github.com/penpot/penpot/pull/10145)) +- Show resolved values in font family token combobox when pasting comma-separated values [#10212](https://github.com/penpot/penpot/issues/10212) (PR: [#10215](https://github.com/penpot/penpot/pull/10215)) +- Fix MCP server status toggle persistence and missing workspace connection options [#10292](https://github.com/penpot/penpot/issues/10292) (PR: [#10226](https://github.com/penpot/penpot/pull/10226)) +- Allow pasting comma-separated emails in the invite members modal [#10173](https://github.com/penpot/penpot/issues/10173) (PR: [#10186](https://github.com/penpot/penpot/pull/10186)) + ## 2.16.0 ### :boom: Breaking changes & Deprecations From 960b90253fbc818ef07596144446bcaf9bd78a31 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 22 Jun 2026 09:04:13 +0200 Subject: [PATCH 4/4] :arrow_up: Update root deps --- package.json | 6 +- pnpm-lock.yaml | 342 ++++++++++++++++++++++++------------------------- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/package.json b/package.json index 1aed4629d2..a47ddb3581 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@types/node": "^25.9.2", - "esbuild": "^0.28.0", + "@types/node": "^26.0.0", + "esbuild": "^0.28.1", "mdts": "^0.20.3", "nrepl-client": "^0.3.0", - "opencode-ai": "^1.17.0" + "opencode-ai": "^1.17.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b42a208a2a..b28254a997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: devDependencies: '@types/node': - specifier: ^25.9.2 - version: 25.9.2 + specifier: ^26.0.0 + version: 26.0.0 esbuild: - specifier: ^0.28.0 - version: 0.28.0 + specifier: ^0.28.1 + version: 0.28.1 mdts: specifier: ^0.20.3 version: 0.20.3 @@ -21,163 +21,163 @@ importers: specifier: ^0.3.0 version: 0.3.0 opencode-ai: - specifier: ^1.17.0 - version: 1.17.0 + specifier: ^1.17.9 + version: 1.17.9 packages: - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -194,8 +194,8 @@ packages: '@simple-git/argv-parser@1.1.1': resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} - '@types/node@25.9.2': - resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -333,8 +333,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -545,72 +545,72 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} - opencode-ai@1.17.0: - resolution: {integrity: sha512-YDRKBJ469vAK9Vtx5EMbRSo/BU+iL+Lit0sbGtorjC4bU6tb76pKTIhD11tnseRYx6MLv4XrypPDyZedEIk02A==} + opencode-ai@1.17.9: + resolution: {integrity: sha512-Q2J6iFI4+dg1fZrHpq6FkxbNtlUD2A/u2+Y+tOcG5ROCHc3B3vlZkS32glqmVdIL6dkCYARuEhs/yWZIiD+04w==} cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true - opencode-darwin-arm64@1.17.0: - resolution: {integrity: sha512-heL151oyckrFxpu8Bdf9BTYdXb4OTCXwTZ8NI+O1iGakzeRLkNnoVER76uS6DO6Aoj/De8xUOub3o0yCn0Kuwg==} + opencode-darwin-arm64@1.17.9: + resolution: {integrity: sha512-ICGSslfk6C4LQfaeAwpOsvZxHOqdiGXAXtC6XBimibVrs4P/ge8lO6Iwc+XmX28t4IFRPuVOLy42r+P5xCfVWA==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.17.0: - resolution: {integrity: sha512-rwWQDyioFd4p2WrVbenBPYy3u01R8FyMrbOjgj1003CXRuY5W3UmsgoIlFwJUA09EGHiu0rvU4dwA0WaLhsfsA==} + opencode-darwin-x64-baseline@1.17.9: + resolution: {integrity: sha512-EhB5NUOoOvkNwSBzY2m+OImcUBHc84EIW4TCRokCVKPCG/KjT8RYov4vwuJ7uzrEELv6g108kRJEgEo8iAn+mQ==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.17.0: - resolution: {integrity: sha512-8ebo4FZI1LhGXW7Yihm8J2J550ulLa50RkznRJrufod63ki6D6g/VrH5PkvAXNwpIQ7wZU1QnHUmgRb6OSF5ww==} + opencode-darwin-x64@1.17.9: + resolution: {integrity: sha512-15qUEEv9JICCKCU9egwX8j4DjspV26ksI9Okk5yLx9O9HA/qJFdWB/3hfxMaA57fhlxW3z7jxX/JnkMuXCzX4w==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.17.0: - resolution: {integrity: sha512-MsgHfzE6+feVsrUMBvPoCcE5QF8EMcufkSMgbdJCnyRXS/v6cuoaRPNz5sIMknpfPnGaCUnHYLiyOtjeEe0NNA==} + opencode-linux-arm64-musl@1.17.9: + resolution: {integrity: sha512-2sP723jmLqMVrkVbRhRWy2KR0DA1cyJcVEP5UydLi9Cvs6YecfLgygi7niIMJ3y1ZVR0j5te2jazmtLutf70PA==} cpu: [arm64] os: [linux] libc: [musl] - opencode-linux-arm64@1.17.0: - resolution: {integrity: sha512-T21kUGraOA7EBLwOg+SELrc6vAK8l3Vj5JXrOtm9+OQEUIL8yU4yyJlgxLSTh2RnGBhsOGxPwLm2L1o0nmy6ow==} + opencode-linux-arm64@1.17.9: + resolution: {integrity: sha512-W0KpNuz7l2etV7LNXbl0H99yhe7RmG3uZ/nyL0xGcxiMPFHQJ5a2TNbFz0nRaeqiSfY7VY+hziOMGX3cL48XqA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.17.0: - resolution: {integrity: sha512-rW9DU2oiMWFDQtM1O8I96cjQu7bpKkv+Nmh0VyK8dUjPx2vsTl4eNBVQmLSCevkSCd81rP8uu8pmZ3cr2xxO+A==} + opencode-linux-x64-baseline-musl@1.17.9: + resolution: {integrity: sha512-tuY7Zjue3KQMtITGsQzTvI4TMcPVDXiG2u+9ZiRgG5NgYLCTXacS/ri3M043cNAFY7bYA4lNThTySG+TYhJ1Ww==} cpu: [x64] os: [linux] libc: [musl] - opencode-linux-x64-baseline@1.17.0: - resolution: {integrity: sha512-f1kDGqOGUQxrgWkNmZSVAVzD0xAxTfkXRIukHNw7r3IblgY0+PZB5XEq8KDMskzdJRwdYqv2cPyjv+D6pThkcQ==} + opencode-linux-x64-baseline@1.17.9: + resolution: {integrity: sha512-yke2Pi2FRsSRhCIMDEN+RmNICRgXiYBZNbI8hVJTQnETv9CPF6wwrqRALPoysflpo5d/xzURP4kqZvN7PvJdfA==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.17.0: - resolution: {integrity: sha512-upbH4j5PDwL+7mFRUYclK708kJ0zq1snnb+r2rtHxWSbNTfLd8ZJnUS+rXuPpymWHWdvwB8STl/66lP63dt/cQ==} + opencode-linux-x64-musl@1.17.9: + resolution: {integrity: sha512-CqhEDkVlv1yfHJzbYoelrn2SO3RSHfDAUIwaaxlcfoSGTs2dwIUR3wL2Giufyc++J4tS7/s+Bp4jWxVIFmIkUg==} cpu: [x64] os: [linux] libc: [musl] - opencode-linux-x64@1.17.0: - resolution: {integrity: sha512-WIH0gewBkD3gt7K3R12/1KqV4nLIEVuzTJc1zBnPcGPOnR9HEid5qxGwgbM0DyfQC9pkhCszHT2Xx71TEScEGg==} + opencode-linux-x64@1.17.9: + resolution: {integrity: sha512-RQxL4Ed4XKbfaxO+pqCn3w2JTJwhq+gllYQ/lxmFqJmFHkg0GGINI3UzzPY5kKVLheD9Cu4yXcSgF/UBaxeUBg==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.17.0: - resolution: {integrity: sha512-iAvHYUS0GRJ+Utr1d4X4KwxmVi0nTM08ZhPkJtF1yzg38oTtHmxdDnkzTd2IIc/79XOfnRMzq47ejwnz9yWp7g==} + opencode-windows-arm64@1.17.9: + resolution: {integrity: sha512-HlBeSsEyoUp87a9mWIh7du2OAgmtMv/JE+sncRLArTl8KF3Iq50+H6PGZOyBmHXMNsTpL7xunvPV9I5PHdQvRQ==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.17.0: - resolution: {integrity: sha512-A+jlLAvIiLUb8Z0mPy9orTPpJQplOGAiTbNM/If5cUvEvBnifwkXOg4YCYEcUwC0hEfbyV9uJRLTyvtTOR8z5g==} + opencode-windows-x64-baseline@1.17.9: + resolution: {integrity: sha512-H2vurVlEAl3+fbjBLwgQ9/MiaqatdANmrd+2WXcapBx5FGdbjrqywiMxZrLD0HElDKbGcqcC+D28SbemYNEn8g==} cpu: [x64] os: [win32] - opencode-windows-x64@1.17.0: - resolution: {integrity: sha512-ayaNoZnxcF18LVO1zO7hscGpMZGrWaUDMpe2XHmLOz7x6yKECo3HuK0IwhXRBtsaq+KZ68pWYUjp54pgEWcduw==} + opencode-windows-x64@1.17.9: + resolution: {integrity: sha512-j+RDdFfiG0rstoJD0ZtaGOto+bJqFXns7IxSjls48RuiqvhacElaLHL2C+s2V8Ryy6HbA6HMSGqujbRsURsNEg==} cpu: [x64] os: [win32] @@ -741,8 +741,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -778,82 +778,82 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@kwsites/file-exists@1.1.1': @@ -870,9 +870,9 @@ snapshots: dependencies: '@simple-git/args-pathspec': 1.0.3 - '@types/node@25.9.2': + '@types/node@26.0.0': dependencies: - undici-types: 7.24.6 + undici-types: 8.3.0 accepts@2.0.0: dependencies: @@ -986,34 +986,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.28.0: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escape-html@1.0.3: {} @@ -1263,55 +1263,55 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - opencode-ai@1.17.0: + opencode-ai@1.17.9: optionalDependencies: - opencode-darwin-arm64: 1.17.0 - opencode-darwin-x64: 1.17.0 - opencode-darwin-x64-baseline: 1.17.0 - opencode-linux-arm64: 1.17.0 - opencode-linux-arm64-musl: 1.17.0 - opencode-linux-x64: 1.17.0 - opencode-linux-x64-baseline: 1.17.0 - opencode-linux-x64-baseline-musl: 1.17.0 - opencode-linux-x64-musl: 1.17.0 - opencode-windows-arm64: 1.17.0 - opencode-windows-x64: 1.17.0 - opencode-windows-x64-baseline: 1.17.0 + opencode-darwin-arm64: 1.17.9 + opencode-darwin-x64: 1.17.9 + opencode-darwin-x64-baseline: 1.17.9 + opencode-linux-arm64: 1.17.9 + opencode-linux-arm64-musl: 1.17.9 + opencode-linux-x64: 1.17.9 + opencode-linux-x64-baseline: 1.17.9 + opencode-linux-x64-baseline-musl: 1.17.9 + opencode-linux-x64-musl: 1.17.9 + opencode-windows-arm64: 1.17.9 + opencode-windows-x64: 1.17.9 + opencode-windows-x64-baseline: 1.17.9 - opencode-darwin-arm64@1.17.0: + opencode-darwin-arm64@1.17.9: optional: true - opencode-darwin-x64-baseline@1.17.0: + opencode-darwin-x64-baseline@1.17.9: optional: true - opencode-darwin-x64@1.17.0: + opencode-darwin-x64@1.17.9: optional: true - opencode-linux-arm64-musl@1.17.0: + opencode-linux-arm64-musl@1.17.9: optional: true - opencode-linux-arm64@1.17.0: + opencode-linux-arm64@1.17.9: optional: true - opencode-linux-x64-baseline-musl@1.17.0: + opencode-linux-x64-baseline-musl@1.17.9: optional: true - opencode-linux-x64-baseline@1.17.0: + opencode-linux-x64-baseline@1.17.9: optional: true - opencode-linux-x64-musl@1.17.0: + opencode-linux-x64-musl@1.17.9: optional: true - opencode-linux-x64@1.17.0: + opencode-linux-x64@1.17.9: optional: true - opencode-windows-arm64@1.17.0: + opencode-windows-arm64@1.17.9: optional: true - opencode-windows-x64-baseline@1.17.0: + opencode-windows-x64-baseline@1.17.9: optional: true - opencode-windows-x64@1.17.0: + opencode-windows-x64@1.17.9: optional: true parseurl@1.3.3: {} @@ -1464,7 +1464,7 @@ snapshots: uc.micro@2.1.0: {} - undici-types@7.24.6: {} + undici-types@8.3.0: {} unpipe@1.0.0: {}