penpot/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
Dr. Dominik Jain 8efbd9b5d3
🎉 Allow MCP to work with multiple files in parallel (#11927)
* 🎉 Add multi-file session routing to the MCP server

Session routing:
- Track each user's Penpot connections by session ID.
- Dispatch explicit sessions directly; otherwise discover the sole session.
- Return a retryable error when discovery fails or is incomplete.

Plugin and tools:
- Initialize connections with a fresh session ID and file metadata.
- Share the optional sessionId schema and place it last in tool inputs.

Validation:
- 84 tests, server type checking, and MCP formatting checks pass.
- Disable test logging to avoid the logging worker shutdown hang.

AI-assisted-by: gpt-6

* 🎉 Support independent MCP connections

- Require explicit connection intent for each integrated workspace.
- Derive short session IDs from the Penpot app instance and file.
- Show and copy session IDs in the MCP menu and standalone plugin UI.
- Stop stale callbacks and reconnect attempts after disconnect.

Validate with frontend and plugin tests, type checking, live multi-tab
checks, and standalone UI checks with a simulated connection. Server
tests pass with --test-force-exit; the normal runner can hang on shutdown.

AI-assisted-by: gpt-6

* 📚 Document multi-file MCP sessions

- Explain independent connections, session selection, and copying IDs.
- Describe session ID lifetimes for integrated and standalone plugins.
- Correct browser focus, connection, and recovery guidance.

Validate with the documentation site build and diff checks.

AI-assisted-by: gpt-6

* ✨ Change behavior on session ID duplication

* ✨ Open MCP toolbar menu on hover and match menu style

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Co-authored-by: elhombretecla <delacruzgarciajuan@gmail.com>
2026-10-02 14:22:52 +02:00

189 lines
8.8 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-mcp-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[potok.v2.core :as ptk]))
(t/deftest test-update-mcp-status
(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 [:mcp :enabled])))))
(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 [:mcp :enabled]))))))
(t/deftest test-update-mcp-connection-status
(t/testing "sets connection status to connected"
(let [state {:mcp {:connection-requested true :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-requested true :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-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 :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))))))))
(t/deftest ^:async test-enable-does-not-connect
(let [events (atom [])]
(await (a/observe (ptk/watch (mcp/update-mcp-status true) {} (rx/empty))
:on-next #(swap! events conj %)))
(t/is (empty? @events))))
(t/deftest ^:async test-connect-is-local
(let [event (mcp/connect-mcp)
state (ptk/update event {:mcp {:enabled true :token-valid true}})
events (atom [])]
(t/is (true? (get-in state [:mcp :connection-requested])))
(await (a/observe (ptk/watch event state (rx/empty))
:on-next #(swap! events conj (ptk/type %))))
(t/is (= [:app.main.data.workspace.mcp/connect] @events))))
(t/deftest test-disconnect-clears-connection-intent
(let [state {:mcp {:connection-requested true
:connection-status "connecting"
:session-id "pq3gxqddgj"}}
result (ptk/update (mcp/user-disconnect-mcp) state)]
(t/is (false? (get-in result [:mcp :connection-requested])))
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-init-clears-previous-file-connection
(let [state {:profile {:props {:mcp-enabled true}}
:mcp {:connection-requested true
:connection-status "connected"
:session-id "pq3gxqddgj"}}
result (ptk/update (mcp/init) state)]
(t/is (false? (get-in result [:mcp :connection-requested])))
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-late-status-cannot-reconnect
(let [state {:mcp {:connection-requested false :connection-status "disconnected"}}]
(t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state)))))
(t/deftest test-plugin-callbacks-stop-with-workspace
(doseq [stop-event [:app.main.data.workspace/finalize-workspace
:app.main.data.workspace.mcp/init]]
(let [stream (rx/subject)
extension (atom nil)
calls (atom 0)
closed (atom 0)]
(with-redefs [dp/start-plugin! (fn [_ extensions]
(reset! extension (.-mcp extensions)))
dp/close-plugin! (fn [_] (swap! closed inc))]
(ptk/effect (#'mcp/init-mcp-plugin {:token "test-token"}) nil stream)
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/push! stream (ptk/data-event stop-event))
(t/is (= 1 @closed))
(t/is (false? (.isConnectionRequested @extension)))
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/end! stream)))))