diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 58ce2f5060..dfb780cf5c 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -17,6 +17,7 @@ [app.main.data.profile :as du] [app.main.store :as st] [app.plugins.register :as preg] + [app.util.globals :as globals] [app.util.timers :as ts] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] @@ -135,6 +136,37 @@ (effect [_ _ _] (stop-reconnect-watcher!)))) +(defn- extension-hello? + "Checks if a window message is a browser extension's request for the + MCP session, posted on this page by its content script." + [event] + (and (identical? (unchecked-get event "source") globals/window) + (= (unchecked-get event "origin") (.-origin globals/location)) + (= "penpot-mcp-extension:hello" + (some-> (unchecked-get event "data") (unchecked-get "type"))))) + +(defn extension-status + "Returns the connection status to announce to a browser extension. The + plugin also reports `disconnected` while it reconnects, so the + extension, which forgets a session on `disconnected`, only hears it + once the user has stopped the connection." + [status connection-requested?] + (if (and (= status "disconnected") connection-requested?) + "connecting" + status)) + +(defn- announce-session + "Posts the MCP session of this page to a browser extension, which uses it + to wake this tab when the browser freezes it." + [token status session-id] + (.postMessage globals/window + #js {:type "penpot-mcp:session" + :sessionId session-id + :serverUrl (str cf/mcp-ws-uri) + :token token + :status status} + (.-origin globals/location))) + (defn- init-mcp-plugin "Waits the plugin runtime and initializes the bundled MCP plugin." [{:keys [token]}] @@ -151,6 +183,20 @@ (rx/filter (ptk/type? ::init) stream)) active? (atom true) + + ;; Whether a browser extension asked for the session; the token + ;; is only posted on the page after that. + announce? (atom false) + + on-message + (fn [event] + (when (extension-hello? event) + (reset! announce? true) + (let [{:keys [connection-status connection-requested session-id]} (get @st/state :mcp)] + (announce-session token + (extension-status connection-status connection-requested) + session-id)))) + extension #js {:getToken (constantly token) :getServerUrl #(str cf/mcp-ws-uri) :isConnectionRequested #(and @active? @@ -162,6 +208,10 @@ (get-in @st/state [:mcp :connection-requested])) (start-reconnect-watcher)) (st/emit! (update-mcp-connection-status status session-id)) + (when @announce? + (announce-session token + (extension-status status (get-in @st/state [:mcp :connection-requested])) + session-id)) (log/info :hint "MCP STATUS" :status status))) :on @@ -182,8 +232,15 @@ (rx/take 1) (rx/subs! (fn [_] (reset! active? false) + (.removeEventListener globals/window "message" on-message) + (when @announce? + (announce-session token "disconnected" nil)) (stop-reconnect-watcher!) (dp/close-plugin! default-manifest)))) + (.addEventListener globals/window "message" on-message) + ;; A content script that said hello before this listener existed + ;; says it again + (.postMessage globals/window #js {:type "penpot-mcp:ready"} (.-origin globals/location)) (dp/start-plugin! manifest #js {:mcp extension}))))) (defn- stop-mcp-plugin diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index 5ac6cd3130..8b398ba53d 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -84,6 +84,9 @@ goog.scope(function () { forward() {}, go() {}, }; + // `app.main.data.workspace.mcp` talks to a browser extension through + // window messages; nothing receives them here. + mockWindow.postMessage = function () {}; return mockWindow; } })(); diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 802be3ae2f..7462eb944c 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -58,6 +58,18 @@ (t/is (false? (get-in result [:profile :props :mcp-enabled]))) (t/is (false? (get-in result [:mcp :enabled])))))) +(t/deftest test-extension-status + (t/testing "keeps a session the plugin is reconnecting" + (t/is (= "connecting" (mcp/extension-status "disconnected" true)))) + + (t/testing "reports the disconnect once the user has stopped the connection" + (t/is (= "disconnected" (mcp/extension-status "disconnected" false))) + (t/is (= "disconnected" (mcp/extension-status "disconnected" nil)))) + + (t/testing "passes other statuses through" + (t/is (= "connected" (mcp/extension-status "connected" true))) + (t/is (= "error" (mcp/extension-status "error" true))))) + (t/deftest test-update-mcp-connection-status (t/testing "sets connection status to connected" (let [state {:mcp {:connection-requested true :connection-status "disconnected"}}