From be17fabc6a5a013efc749f0bf44942854ab10c3c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 12:20:51 +0000 Subject: [PATCH] :tada: Introduce global plugins and move MCP to application scope Global plugin lifecycle: - Add optional manifest scope "global" for plugins that remain active across dashboard and workspace navigation within one browser tab. Existing plugins retain workspace scope by default. - Preserve global sandboxes when a workspace ends or another plugin loads, including when that other plugin fails to initialize. - Avoid duplicate instances of an already-running global plugin. - Close global plugins on explicit closure or logout, with idempotent cleanup of listeners, timers and UI resources. Global scope does not imply automatic startup or persistence across a browser reload. Plugin API and navigation: - Keep a permanently live penpot facade whose getters follow the current workspace, while user, theme and UI facilities remain available on the dashboard. - Expose penpotMgmt to global plugins with workspace metadata, none/loading/ready status and workspacechange subscriptions. - Add awaitable openFile(fileId, {teamId}) navigation in the current tab, defaulting to the current team and resolving only once the requested file and its active page are ready. - Reject invalid identifiers, failed or superseded navigation and waits exceeding 30 seconds; release navigation watchers on completion. - Guard workspace-bound operations while no workspace is ready and return null from root, currentFile and currentPage in that state. - Require content:read for the initial management API. Callers must reacquire file, page and shape references after changing files. MCP lifecycle and connection controls: - Make MCP a global plugin and initialize its integrated controller per authenticated application session instead of per workspace. - Load the enabled integration with a valid token on the dashboard as well as in a file, retaining its existing hidden-iframe implementation. - Keep enabling and connecting separate: an enabled plugin starts idle, and each tab connects explicitly through dashboard or workspace controls. - Preserve the iframe, WebSocket, heartbeat and reconnect machinery during navigation; disconnect on request and close on disable/logout. MCP sessions and execution: - Derive the short MCP session ID solely from currentUser.sessionId, excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding to produce a stable, copyable 10-character identifier. - Allow sessions without an open file and update workspace metadata over the existing connection without replacing its routing identity. - Refresh context after the connection handshake so initialization does not leave stale file metadata. - Expose penpotMgmt to execute_code, preserve execution storage across navigation and skip layout waits when no workspace is ready. - Update API types, tool guidance, documentation and the changelog. Validation: - Cover authenticated startup, workspace survival, logout cleanup, readiness, navigation failure, duplicate loads and session routing. - Verify that short IDs remain stable across files and reconnects and that disconnect cancels pending asynchronous ID initialization. - Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests, builds, TypeScript checks and frontend/runtime lint. - Pass all 73 MCP server tests with --test-force-exit; the normal runner still stays alive after assertions finish. - Verify a live dashboard-to-file-to-dashboard round trip without a document or iframe reload, preserving facade identity and storage. AI-assisted-by: gpt-6 --- common/src/app/common/types/plugins.cljc | 1 + docs/mcp/index.md | 9 +- frontend/src/app/main.cljs | 2 + frontend/src/app/main/data/plugins.cljs | 6 +- frontend/src/app/main/data/workspace.cljs | 13 +-- frontend/src/app/main/data/workspace/mcp.cljs | 30 ++++++- .../src/app/main/ui/dashboard/sidebar.cljs | 23 +++++ frontend/src/app/plugins/api.cljs | 4 + frontend/src/app/plugins/events.cljs | 20 ++++- frontend/src/app/plugins/management.cljs | 78 +++++++++++++++++ frontend/src/app/plugins/register.cljs | 1 + .../data/workspace_mcp_test.cljs | 37 +++++++- .../plugins/management_test.cljs | 83 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + mcp/README.md | 12 ++- mcp/packages/common/src/types.ts | 5 +- mcp/packages/plugin/public/manifest.json | 1 + .../plugin/src/ConnectionMetadata.test.ts | 71 ++++++++++++--- mcp/packages/plugin/src/SessionId.test.ts | 14 +-- mcp/packages/plugin/src/SessionId.ts | 4 +- mcp/packages/plugin/src/index.d.ts | 1 + mcp/packages/plugin/src/main.ts | 12 ++- mcp/packages/plugin/src/plugin.ts | 30 ++++++- .../task-handlers/ExecuteCodeTaskHandler.ts | 3 +- .../server/src/PluginBridge.sessions.test.ts | 26 ++++++ mcp/packages/server/src/PluginBridge.ts | 12 ++- .../server/src/dispatch/TaskDispatcher.ts | 2 +- .../server/src/tools/ExecuteCodeTool.ts | 6 +- plugins/CHANGELOG.md | 2 + plugins/libs/plugin-types/index.d.ts | 27 +++++- .../libs/plugins-runtime/src/lib/api/index.ts | 56 ++++++++++-- .../src/lib/create-sandbox.spec.ts | 1 + .../plugins-runtime/src/lib/create-sandbox.ts | 9 +- .../src/lib/load-plugin-real-path.spec.ts | 86 ++++++++++++++++++- .../plugins-runtime/src/lib/load-plugin.ts | 38 +++++--- .../src/lib/models/manifest.schema.ts | 1 + .../plugins-runtime/src/lib/plugin-manager.ts | 13 +-- 37 files changed, 647 insertions(+), 94 deletions(-) create mode 100644 frontend/src/app/plugins/management.cljs create mode 100644 frontend/test/frontend_tests/plugins/management_test.cljs diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 035be0efb3..b9a1ca5330 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -58,6 +58,7 @@ [:map [:plugin-id :string] [:version {:optional true} :int] + [:scope {:optional true} [:enum "workspace" "global"]] [:name [:string {:max (:name registry-entry-max-lengths)}]] [:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]] [:host [:string {:max (:host registry-entry-max-lengths)}]] diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 815a468fea..47a4c55803 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -74,16 +74,17 @@ There are three key pieces: Some important concepts for users: * **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. -* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file. +* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID derived from the tab's user session. Several tabs can connect independently, including tabs showing the same file. * **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses. ### Working with multiple files Connect the tabs you want to use: -* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab. +* **Remote MCP**: choose **MCP: Connect** in the dashboard sidebar or **MCP → Connect** in a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. * **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. * Connecting or disconnecting one tab does not disconnect the others. +* A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file. Choose a session for your agent: @@ -176,7 +177,7 @@ When this happens, MCP fails fast instead of waiting for a long task timeout: To recover: * If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt. -* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one. +* If the tab was unloaded or reloaded, connect again from the dashboard or a file. Copy the new session ID if your agent was using the previous one. To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: @@ -271,7 +272,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted 1. Open **Your account → Integrations**. 2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. -3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly. +3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each tab explicitly. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. 5. Once enabled, you will see: diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index c7d87ba8ba..06ffc849da 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -16,6 +16,7 @@ [app.main.data.event :as ev] [app.main.data.profile :as dp] [app.main.data.websocket :as ws] + [app.main.data.workspace.mcp :as mcp] [app.main.errors] [app.main.features :as feat] [app.main.rasterizer :as thr] @@ -81,6 +82,7 @@ (watch [_ _ stream] (rx/merge (rx/of (ev/initialize) + (mcp/initialize) (dp/refresh-profile)) ;; Watch for profile deletion events diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 33d898cf69..e9b15e0849 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -58,7 +58,7 @@ (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) (defn start-plugin! - [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] + [{:keys [plugin-id name version description host code permissions allow-background scope]} ^js extensions] (let [load-plugin (unchecked-get ug/global "ɵloadPlugin")] (if (fn? load-plugin) (-> (load-plugin @@ -69,6 +69,7 @@ :host host :code code :allowBackground (boolean allow-background) + :scope scope :permissions (apply array permissions)} nil extensions) @@ -82,7 +83,7 @@ :action "start-plugin!")))) (defn- load-plugin! - [{:keys [plugin-id name version description host code icon permissions]}] + [{:keys [plugin-id name version description host code icon permissions scope]}] (st/emit! (pflag/initialize plugin-id version) (save-current-plugin plugin-id)) @@ -96,6 +97,7 @@ :host host :code code :icon icon + :scope scope :permissions (apply array permissions)} (fn [] (st/emit! (remove-current-plugin plugin-id)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index b04bf5394b..e4b79d7b66 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -55,7 +55,6 @@ [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layout :as layout] [app.main.data.workspace.libraries :as dwl] - [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.path :as dwdp] @@ -77,7 +76,6 @@ [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] - [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] @@ -259,15 +257,8 @@ ptk/WatchEvent (watch [_ _ _] - (rx/merge - (rx/of (dp/check-open-plugin) - (fdf/fix-deleted-fonts-for-local-library file-id)) - (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)))))) + (rx/of (dp/check-open-plugin) + (fdf/fix-deleted-fonts-for-local-library file-id))))) (defn- compute-shape-stats "Compute shape statistics in a single pass over pages-index. diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 74884289c4..f3e3817239 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -12,13 +12,14 @@ [app.common.uri :as u] [app.config :as cf] [app.main.broadcast :as mbc] + [app.main.data.auth :as-alias auth] [app.main.data.plugins :as dp] [app.main.data.profile :as du] - [app.main.data.workspace :as-alias dw] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] [beicon.v2.core :as rx] + [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) (def reconnect-fallback-interval 60000) @@ -36,6 +37,7 @@ :plugin-id preg/mcp-plugin-id :description "This plugin enables interaction with the Penpot MCP server" :allow-background true + :scope "global" :permissions #{"user:read" "library:read" "library:write" @@ -147,7 +149,7 @@ (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) stopper-s (rx/merge - (rx/filter (ptk/type? ::dw/finalize-workspace) stream) + (rx/filter (ptk/type? ::auth/logged-out) stream) (rx/filter (ptk/type? ::stop-mcp-plugin) stream) (rx/filter (ptk/type? ::init) stream)) @@ -234,7 +236,7 @@ ptk/WatchEvent (watch [_ state stream] (let [stopper-s (rx/merge - (rx/filter (ptk/type? ::dw/finalize-workspace) stream) + (rx/filter (ptk/type? ::auth/logged-out) stream) (rx/filter (ptk/type? ::init) stream)) mcp-state (get state :mcp)] @@ -274,3 +276,25 @@ (stop-mcp-plugin)))))) (rx/take-until stopper-s)))))) + +(defn initialize + "Initialize the MCP controller for each authenticated app session." + [] + (ptk/reify ::initialize + ptk/WatchEvent + (watch [_ _ stream] + (if (contains? cf/flags :mcp) + (->> stream + (rx/filter #(or (ptk/type? ::du/set-profile %) + (ptk/type? ::auth/logged-out %))) + (rx/map #(when (ptk/type? ::du/set-profile %) + (:id (deref %)))) + (rx/pipe (rxo/distinct-contiguous)) + (rx/switch-map + (fn [profile-id] + (if profile-id + (->> (rx/from (preg/wait-for-runtime)) + (rx/map (fn [_] (init)))) + (rx/empty)))) + (rx/take-until (rx/filter (ptk/type? ::initialize) stream))) + (rx/empty))))) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 2f37187244..386d1dc791 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -17,6 +17,7 @@ [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] + [app.main.data.workspace.mcp :as mcp] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] @@ -255,6 +256,13 @@ (get default-project :id) team-id (get team :id) + mcp-state (mf/deref refs/mcp) + mcp-requested? (:connection-requested mcp-state) + toggle-mcp (mf/use-fn + (mf/deps mcp-requested?) + #(st/emit! (if mcp-requested? + (mcp/user-disconnect-mcp) + (mcp/connect-mcp)))) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -382,6 +390,21 @@ [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] + (when (and (contains? cf/flags :mcp) + (:enabled mcp-state) + (:token-valid mcp-state)) + [:div {:class (stl/css :sidebar-content-section)} + [:ul {:class (stl/css :sidebar-nav)} + [:li {:class (stl/css :sidebar-nav-item)} + [:> link* {:action toggle-mcp + :keyboard-action toggle-mcp + :class (stl/css :sidebar-link)} + [:span {:class (stl/css :element-title)} + "MCP: " + (if mcp-requested? + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]]) + [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 226b324c1f..2f0944f800 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -43,6 +43,7 @@ [app.plugins.history :as history] [app.plugins.library :as library] [app.plugins.local-storage :as local-storage] + [app.plugins.management :as management] [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.reflow :as wrfp] @@ -88,6 +89,9 @@ :$plugin {:enumerable false :get (fn [] plugin-id)} ;; Public properties + :management + {:get (fn [] (management/create-context))} + :version {:this true :get (constantly (:base cf/version))} diff --git a/frontend/src/app/plugins/events.cljs b/frontend/src/app/plugins/events.cljs index 884a1e0bac..51ac7c596f 100644 --- a/frontend/src/app/plugins/events.cljs +++ b/frontend/src/app/plugins/events.cljs @@ -10,6 +10,7 @@ [app.main.data.helpers :as dsh] [app.main.store :as st] [app.plugins.file :as file] + [app.plugins.management :as management] [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.shape :as shape] @@ -19,6 +20,21 @@ (defmulti handle-state-change (fn [type _ _ _ _] type)) +(defmethod handle-state-change "workspacechange" + [_ _ old-val new-val _] + (let [old-workspace (management/workspace-context old-val) + new-workspace (management/workspace-context new-val)] + (if (= old-workspace new-workspace) + ::not-changed + (clj->js new-workspace)))) + +(defmethod handle-state-change "logout" + [_ _ old-val new-val _] + (if (and (:profile-id old-val) + (not= (:profile-id old-val) (:profile-id new-val))) + ::void + ::not-changed)) + (defmethod handle-state-change "finish" [_ _ old-val new-val _] (let [old-file-id (:current-file-id old-val) @@ -123,7 +139,9 @@ (try (let [result (handle-state-change type plugin-id old-val new-val props)] (when (not= ::not-changed result) - (debounced-callback result))) + (if (contains? #{"workspacechange" "logout"} type) + (safe-callback result) + (debounced-callback result)))) (catch :default cause (.error js/console cause))))) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs new file mode 100644 index 0000000000..bff5d9292d --- /dev/null +++ b/frontend/src/app/plugins/management.cljs @@ -0,0 +1,78 @@ +;; 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 app.plugins.management + (:require + [app.common.uuid :as uuid] + [app.main.data.helpers :as dsh] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.object :as obj])) + +(defn workspace-context + [state] + (let [params (rt/get-params state) + file-id (when (= :workspace (rt/lookup-name state)) + (uuid/parse* (:file-id params))) + ready? (and file-id + (nil? (:exception state)) + (= file-id (:current-file-id state) (:workspace-ready state)) + (some? (:current-page-id state)) + (some? (dsh/lookup-page state file-id (:current-page-id state))))] + {:status (cond ready? "ready" file-id "loading" :else "none") + :fileId (some-> file-id str) + :fileName (when file-id (:name (dsh/lookup-file state file-id))) + :teamId (some-> (:current-team-id state) str)})) + +(defn open-file + [file-id options] + (let [file-id (uuid/parse* file-id) + team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (cond + (or (nil? file-id) (nil? team-id)) + (js/Promise.reject (js/Error. "Expected a file UUID and a team UUID")) + + (and (= "ready" (:status (workspace-context @st/state))) + (= file-id (:current-file-id @st/state)) + (= team-id (:current-team-id @st/state))) + (js/Promise.resolve nil) + + :else + (js/Promise. + (fn [resolve reject] + (let [key (js/Symbol) + initial-route (:route @st/state) + timer (atom nil) + finish (fn [error] + (remove-watch st/state key) + (js/clearTimeout @timer) + (if error (reject error) (resolve nil)))] + (reset! timer (js/setTimeout #(finish (js/Error. "Timed out opening file")) 30000)) + (add-watch + st/state key + (fn [_ _ _ state] + (let [workspace (workspace-context state) + target? (and (= (str file-id) (:fileId workspace)) + (= (str team-id) (:team-id (rt/get-params state))))] + (cond + (:exception state) + (finish (js/Error. "Unable to open file")) + + (and target? (= "ready" (:status workspace))) + (finish nil) + + (and (not= initial-route (:route state)) (not target?)) + (finish (js/Error. "File navigation was superseded")))))) + (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) + +(defn create-context + [] + (obj/reify {:name "PenpotManagementContext"} + :workspace + {:get (fn [] (clj->js (workspace-context @st/state)))} + :openFile open-file)) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index c70b4f9e7a..fc709427d4 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -102,6 +102,7 @@ {:plugin-id plugin-id :url (str plugin-url) :version vers + :scope (obj/get manifest "scope") :name name :description desc :host origin diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 8bd36d07aa..802be3ae2f 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -8,14 +8,43 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] + [app.plugins.register :as preg] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(t/deftest ^:async test-initialize-once-per-authenticated-session + (let [stream (rx/subject) + events (atom []) + profile {:id (uuid/next)}] + (await + (mock/with-mocks* + {cf/flags #{:mcp} + preg/wait-for-runtime (fn [] (js/Promise.resolve true))} + (let [subscription (rx/subs! #(swap! events conj (ptk/type %)) + (ptk/watch (mcp/initialize) {} stream))] + (try + (rx/push! stream (du/set-profile profile)) + (await (a/wait-for #(= 1 (count @events)))) + (rx/push! stream (du/set-profile profile)) + (rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (a/settle)) + (t/is (= [:app.main.data.workspace.mcp/init] @events)) + (rx/push! stream (ptk/data-event :app.main.data.auth/logged-out)) + (rx/push! stream (du/set-profile profile)) + (await (a/wait-for #(= 2 (count @events)))) + (t/is (= [:app.main.data.workspace.mcp/init + :app.main.data.workspace.mcp/init] @events)) + (finally + (rx/dispose! subscription) + (rx/end! stream)))))))) + (t/deftest test-update-mcp-status (t/testing "enables MCP in profile props and mcp state" (let [state {:profile {:props {:mcp-enabled false}} :mcp {}} @@ -151,7 +180,7 @@ (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 +(t/deftest test-init-clears-previous-app-connection (let [state {:profile {:props {:mcp-enabled true}} :mcp {:connection-requested true :connection-status "connected" @@ -165,8 +194,8 @@ (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 +(t/deftest test-plugin-callbacks-stop-with-app-session + (doseq [stop-event [:app.main.data.auth/logged-out :app.main.data.workspace.mcp/init]] (let [stream (rx/subject) extension (atom nil) @@ -179,6 +208,8 @@ (.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 :app.main.data.workspace/finalize-workspace)) + (t/is (zero? @closed)) (rx/push! stream (ptk/data-event stop-event)) (t/is (= 1 @closed)) (t/is (false? (.isConnectionRequested @extension))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs new file mode 100644 index 0000000000..1e11a83367 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -0,0 +1,83 @@ +;; 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.plugins.management-test + (:require + [app.common.uuid :as uuid] + [app.main.store :as st] + [app.plugins.events :as events] + [app.plugins.management :as management] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock])) + +(def file-id (uuid/next)) +(def page-id (uuid/next)) +(def team-id (uuid/next)) + +(def dashboard + {:route {:data {:name :dashboard-recent}} + :current-team-id team-id}) + +(def loading + (assoc dashboard + :route {:data {:name :workspace} + :params {:query {:file-id (str file-id) :team-id (str team-id)}}} + :current-file-id file-id + :workspace-ready file-id)) + +(def ready + (assoc loading + :current-page-id page-id + :files {file-id {:id file-id :name "Design" + :data {:pages-index {page-id {:id page-id :objects {}}}}}})) + +(t/deftest workspace-readiness-requires-an-initialized-page + (t/is (= "none" (:status (management/workspace-context dashboard)))) + (t/is (= "loading" (:status (management/workspace-context loading)))) + (t/is (= "ready" (:status (management/workspace-context ready)))) + (t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard))))))) + +(t/deftest ^:async open-file-waits-for-page-readiness + (let [state (atom dashboard) + seen (atom []) + done? (atom false)] + (await + (mock/with-mocks* + {st/state state + st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) + ([_ & _] nil))} + (let [listener (events/add-listener "workspacechange" (str uuid/zero) + #(swap! seen conj (.-status %)) nil) + result (-> (management/open-file (str file-id) nil) + (.then #(reset! done? true)))] + (try + (await (a/wait-for #(= ["loading"] @seen))) + (t/is (false? @done?)) + (reset! state ready) + (await result) + (t/is (= ["loading" "ready"] @seen)) + (t/is (true? @done?)) + (finally + (events/remove-listener listener)))))))) + +(t/deftest ^:async open-file-rejects-superseded-navigation + (let [state (atom dashboard)] + (await + (mock/with-mocks* + {st/state state + st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) + ([_ & _] nil))} + (let [result (-> (management/open-file (str file-id) nil) + (.then (fn [] nil) (fn [error] (.-message error))))] + (await (a/wait-for #(= loading @state))) + (reset! state {:route {:data {:name :settings-profile}}}) + (t/is (= "File navigation was superseded" (await result)))))))) + +(t/deftest ^:async open-file-rejects-invalid-identifiers + (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) + (.then (fn [] nil) (fn [error] (.-message error))))] + (t/is (= "Expected a file UUID and a team UUID" (await result))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a12c63a205..a17d9b3149 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -73,6 +73,7 @@ [frontend-tests.plugins.interactions-test] [frontend-tests.plugins.library-test] [frontend-tests.plugins.local-storage-test] + [frontend-tests.plugins.management-test] [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] @@ -214,6 +215,7 @@ 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.events-test + 'frontend-tests.plugins.management-test 'frontend-tests.plugins.file-test 'frontend-tests.plugins.flex-test 'frontend-tests.plugins.format-test diff --git a/mcp/README.md b/mcp/README.md index 1661f0d019..7018537793 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -33,6 +33,16 @@ This repository thus contains not only the MCP server implementation itself but also the supporting Penpot MCP Plugin (see section [Repository Structure](#repository-structure) below). +## Global plugin lifecycle + +- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden. +- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it. +- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`. +- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`. +- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise. +- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation. +- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission. + ## Demonstration [![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4) @@ -243,7 +253,7 @@ of the prompt input area. Connect the files you want to use: * Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server. -* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button. +* Each browser tab uses a short session ID derived only from `penpot.currentUser.sessionId`, shown in the plugin UI with a **Copy** button. The ID stays the same across workspace navigation. * Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others. Choose a session for your agent: diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index 3809a14fdd..f9d7cde400 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -1,8 +1,9 @@ /** Metadata identifying one connected Penpot plugin session. */ export interface PenpotSession { sessionId: string; - fileId: string; - fileName: string; + fileId: string | null; + fileName: string | null; + workspaceState?: "none" | "loading" | "ready"; } /** First message sent by the plugin on each new WebSocket connection. */ diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index 022c7ece9c..c67296f1db 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -3,6 +3,7 @@ "code": "plugin.js", "icon": "icon.jpg", "version": 2, + "scope": "global", "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["user:read", "content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] } diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 954eefeb9f..91aadb8188 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -92,7 +92,7 @@ test("sends file metadata before marking a plugin connection ready", async () => }; await ui.message(initialization); const registeredSession = ui.sockets[0].sent[0].session; - assert.equal(registeredSession.sessionId, "pq3gxqddgj"); + assert.equal(registeredSession.sessionId, "ftdmhltgwk"); assert.notEqual(registeredSession.sessionId, sessionId); assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId }); assert.equal( @@ -100,7 +100,10 @@ test("sends file metadata before marking a plugin connection ready", async () => false ); ui.sockets[0].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).status, "connected"); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).status, + "connected" + ); }); test("reconnects with the same session ID and ignores metadata for the old socket", async () => { @@ -163,6 +166,12 @@ test("plugin reads the current file for each connection metadata request", () => runInNewContext(compile("plugin.ts"), { exports: {}, penpot, + penpotMgmt: { + get workspace() { + return { status: "ready", fileId: penpot.currentFile.id, fileName: penpot.currentFile.name }; + }, + on() {}, + }, mcp: undefined, require: () => ({ ExecuteCodeTaskHandler: class {} }), }); @@ -173,17 +182,17 @@ test("plugin reads the current file for each connection metadata request", () => { type: "initialize", penpotUserSessionId: "tab-1", - session: { sessionId: "first", fileId: "file-1", fileName: "First design" }, + session: { sessionId: "first", fileId: "file-1", fileName: "First design", workspaceState: "ready" }, }, { type: "initialize", penpotUserSessionId: "tab-1", - session: { sessionId: "second", fileId: "file-2", fileName: "Second design" }, + session: { sessionId: "second", fileId: "file-2", fileName: "Second design", workspaceState: "ready" }, }, ]); }); -test("integrated connections reuse the short ID across reconnects", async () => { +test("integrated connections reuse the short session ID across reconnects", async () => { const ui = pluginUi(); ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); const initialize = async (requestId: string) => { @@ -194,15 +203,18 @@ test("integrated connections reuse the short ID across reconnects", async () => }); }; await initialize(ui.connect()); - assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj"); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); ui.sockets[0].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj"); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId, + "ftdmhltgwk" + ); ui.sockets[0].close(); [...ui.timers.values()][0](); ui.sockets[1].open(); const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId; await initialize(requestId); - assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj"); + assert.equal(ui.sockets[1].sent[0].session.sessionId, "ftdmhltgwk"); }); test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => { @@ -216,8 +228,9 @@ test("disconnect cancels metadata initialization and ignores a late acknowledgem }); ui.message({ type: "stop-server" }); await pending; - ui.sockets[0].receive({ type: "initialized" }); assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + assert.ok(!ui.messages.some((message) => message.status === "connected")); assert.equal(ui.messages.at(-1).status, "disconnected"); assert.equal(ui.timers.size, 0); }); @@ -243,6 +256,7 @@ test("integrated plugin stays idle until explicitly connected, including while t }, on() {}, }, + penpotMgmt: { on() {} }, mcp: { getToken: () => "test-token", getServerUrl: () => "ws://localhost:4402", @@ -280,7 +294,10 @@ test("standalone session ID survives reconnects and plugin restarts in the same }); assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId); ui.sockets[1].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).sessionId, sessionId); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId, + sessionId + ); const next = pluginUi(); const nextRequestId = next.connect(); @@ -291,3 +308,37 @@ test("standalone session ID survives reconnects and plugin restarts in the same }); assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId); }); + +test("file metadata does not affect the short session ID", async () => { + for (const fileId of [null, "file-1", "file-2"]) { + const ui = pluginUi(); + const requestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId, fileName: fileId }, + }); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); + } +}); + +test("dashboard sessions use the short session ID and publish context changes without reconnecting", async () => { + const ui = pluginUi(); + const requestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId: null, fileName: null, workspaceState: "none" }, + }); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); + ui.sockets[0].receive({ type: "initialized" }); + assert.equal(ui.messages.at(-1).type, "context-request"); + for (const context of [ + { fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + { fileId: null, fileName: null, workspaceState: "none" }, + ]) { + await ui.message({ type: "context-update", context }); + assert.deepEqual(ui.sockets[0].sent.at(-1), { type: "context-update", context }); + } + assert.equal(ui.sockets.length, 1); +}); diff --git a/mcp/packages/plugin/src/SessionId.test.ts b/mcp/packages/plugin/src/SessionId.test.ts index 8837fc7d2b..dbaa3ed162 100644 --- a/mcp/packages/plugin/src/SessionId.test.ts +++ b/mcp/packages/plugin/src/SessionId.test.ts @@ -2,13 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { SessionId } from "./SessionId.ts"; -test("derives a stable 50-bit Base32 identifier from the Penpot user session and file", async () => { - assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); - assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); - assert.equal(await SessionId.forFile("tab-2", "file-1"), "jablgbun4p"); - assert.equal(await SessionId.forFile("tab-1", "file-2"), "7s3kow4ucx"); +test("derives a stable 50-bit Base32 identifier from only the Penpot user session", async () => { + assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk"); + assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk"); + assert.equal(await SessionId.forSession("tab-2"), "cb3oexpdmd"); }); -test("encodes the Penpot user session and file without ambiguous boundaries", async () => { - assert.notEqual(await SessionId.forFile("ab", "c"), await SessionId.forFile("a", "bc")); +test("shortens a UUID session to ten copyable Base32 characters", async () => { + const sessionId = await SessionId.forSession("a7457b7c-cf45-80d4-8008-ba5b1d08e6c8"); + assert.match(sessionId, /^[a-z2-7]{10}$/); }); diff --git a/mcp/packages/plugin/src/SessionId.ts b/mcp/packages/plugin/src/SessionId.ts index e81f3e5a59..22e14b4309 100644 --- a/mcp/packages/plugin/src/SessionId.ts +++ b/mcp/packages/plugin/src/SessionId.ts @@ -2,8 +2,8 @@ export class SessionId { private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; - public static async forFile(penpotUserSessionId: string, fileId: string): Promise { - const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId])); + public static async forSession(penpotUserSessionId: string): Promise { + const input = new TextEncoder().encode(penpotUserSessionId); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); return SessionId.encode(digest); } diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index 1fa11567bb..67a498275f 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,6 +8,7 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; + const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 29e5c0a286..71973ade0b 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -249,6 +249,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { cancelReconnect(); startHeartbeat(); updateConnectionStatus("connected", "Connected"); + parent.postMessage({ type: "context-request" }, "*"); return; } // Track the current task received from the MCP server @@ -349,10 +350,7 @@ window.addEventListener("message", async (event) => { if (!initialization.penpotUserSessionId) { throw new Error("Missing Penpot user session ID"); } - const sessionId = await SessionId.forFile( - initialization.penpotUserSessionId, - initialization.session.fileId - ); + const sessionId = await SessionId.forSession(initialization.penpotUserSessionId); if ( !shouldReconnect || ws !== socket || @@ -377,6 +375,12 @@ window.addEventListener("message", async (event) => { } return; } + if (event.data.type === "context-update") { + if (connectionReady && ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event.data)); + } + return; + } if (event.data.type === "mcp-mode") { isIntegratedRemoteMcp = event.data.integratedRemoteMcp; } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index eb01d2d571..bb55706a10 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -57,7 +57,7 @@ penpot.ui.onMessage< penpotVersion: penpotVersionPrefix, }); } - // connect only when requested in this workspace + // connect only when requested in this tab if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) { penpot.ui.sendMessage({ type: "start-server", @@ -66,15 +66,22 @@ penpot.ui.onMessage< }); } } else if (typeof message === "object" && message.type === "connection-metadata-request") { - const file = penpot.currentFile; - if (file && message.sessionId) { + const workspace = penpotMgmt.workspace; + if (message.sessionId) { const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = { type: "initialize", - session: { sessionId: message.sessionId, fileId: file.id, fileName: file.name }, + session: { + sessionId: message.sessionId, + fileId: workspace.fileId, + fileName: workspace.fileName, + workspaceState: workspace.status, + }, penpotUserSessionId: penpot.currentUser.sessionId, }; penpot.ui.sendMessage(initialization); } + } else if (typeof message === "object" && message.type === "context-request") { + sendWorkspaceContext(); } else if (typeof message === "object" && message.type === "update-connection-status") { mcp?.setMcpStatus(message.status || "unknown", message.sessionId); } else if (typeof message === "object" && message.task && message.id) { @@ -85,6 +92,21 @@ penpot.ui.onMessage< } }); +/** Sends the current workspace metadata without replacing the connection. */ +function sendWorkspaceContext(): void { + const workspace = penpotMgmt.workspace; + penpot.ui.sendMessage({ + type: "context-update", + context: { + fileId: workspace.fileId, + fileName: workspace.fileName, + workspaceState: workspace.status, + }, + }); +} + +penpotMgmt.on("workspacechange", sendWorkspaceContext); + /** * Handles plugin task requests received from the MCP server via WebSocket. * diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 87ea2c7616..2132446f80 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // initialize context, making penpot, penpotUtils, storage and the custom console available this.context = { penpot: penpot, + penpotMgmt: penpotMgmt, storage: {}, console: new ExecuteCodeTaskConsole(), penpotUtils: PenpotUtils, @@ -217,7 +218,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // wait for layout updates prior to executing the supplied code (if method is available) try { // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types - if (penpot.waitForLayoutUpdate) { + if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) { // @ts-ignore await penpot.waitForLayoutUpdate(); } diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts index 1472353588..1a42241169 100644 --- a/mcp/packages/server/src/PluginBridge.sessions.test.ts +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -143,3 +143,29 @@ test("invalid initialization metadata cannot register a session", async () => { assert.equal((await closed)[0], 1008); await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot sessions/); }); + +test("a dashboard session remains routable while its workspace metadata changes", async () => { + const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(socket); + await once(socket, "open"); + const initialized = once(socket, "message"); + socket.send( + JSON.stringify({ + type: "initialize", + session: { sessionId: "tab", fileId: null, fileName: null, workspaceState: "none" }, + }) + ); + await initialized; + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + socket.send(JSON.stringify({ id: request.id, success: true, data: "ok" })); + }); + for (const context of [ + { fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + { fileId: null, fileName: null, workspaceState: "none" }, + ]) { + socket.send(JSON.stringify({ type: "context-update", context })); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "ok"); + assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]); + } +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index f53ed3f849..913b28b26a 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -27,8 +27,9 @@ const connectionInitSchema = z.object({ type: z.literal("initialize"), session: z.object({ sessionId: z.string().min(1).max(128), - fileId: z.string().min(1).max(128), - fileName: z.string(), + fileId: z.string().min(1).max(128).nullable(), + fileName: z.string().nullable(), + workspaceState: z.enum(["none", "loading", "ready"]).optional(), }), }); @@ -170,6 +171,13 @@ export class PluginBridge implements TaskDispatchHost { return; } connection.frozen = false; + if (message?.type === "context-update") { + const context = connectionInitSchema.shape.session + .omit({ sessionId: true }) + .parse(message.context); + Object.assign(connection.session, context); + return; + } if (message?.type === "heartbeat") { return; } diff --git a/mcp/packages/server/src/dispatch/TaskDispatcher.ts b/mcp/packages/server/src/dispatch/TaskDispatcher.ts index b9fdcf17aa..062af6b80c 100644 --- a/mcp/packages/server/src/dispatch/TaskDispatcher.ts +++ b/mcp/packages/server/src/dispatch/TaskDispatcher.ts @@ -31,7 +31,7 @@ export abstract class TaskDispatcher { sessions .map( (session) => - `${session.sessionId}: ${JSON.stringify(session.fileName)} (file ${session.fileId})` + `${session.sessionId}: ${session.fileId ? `${JSON.stringify(session.fileName)} (file ${session.fileId})` : "no active workspace"}` ) .join("\n") ); diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index ee15401005..7f913a5700 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -49,8 +49,10 @@ export class ExecuteCodeTool extends Tool { "Executes JavaScript code in the Penpot plugin context.\n" + "IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " + "which Penpot API functionality is necessary and how to use it.\n" + - "You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " + - "and `storage`.\n" + + "You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" + + "`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " + + "to open a file and wait until it is ready; teamId defaults to the current team. " + + "File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" + "`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " + "stored attributes can be referenced in future calls to this tool, so any intermediate results that " + "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4b45df68b0..8ff05e595b 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ### 🚀 Features +- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout. +- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index b7dbcc02fc..0ef43fdd4d 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4,7 +4,7 @@ */ export interface Penpot extends Omit< Context, - 'addListener' | 'removeListener' + 'addListener' | 'removeListener' | 'management' > { readonly ui: { /** @@ -778,10 +778,32 @@ export interface CommonLayout { remove(): void; } +/** Current workspace of this browser tab. */ +export interface WorkspaceContext { + readonly status: 'none' | 'loading' | 'ready'; + readonly fileId: string | null; + readonly fileName: string | null; + readonly teamId: string | null; +} + +/** Application management API available to global plugins. */ +export interface PenpotMgmt { + readonly workspace: WorkspaceContext; + /** Opens a file in this tab and resolves once its workspace and page are ready. */ + openFile(fileId: string, options?: { teamId?: string }): Promise; + /** Subscribes to workspace availability and metadata changes. */ + on( + type: 'workspacechange', + callback: (workspace: WorkspaceContext) => void, + ): symbol; + off(listenerId: symbol): void; +} + /** * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { + readonly management?: Pick; /** * Returns the current penpot version. */ @@ -1556,6 +1578,8 @@ export interface Ellipse extends ShapeBase { * ``` */ export interface EventsMap { + readonly workspacechange: WorkspaceContext; + readonly logout: void; /** * The `pagechange` event is triggered when the active page in the project is changed. */ @@ -5757,4 +5781,5 @@ export interface Viewport { declare global { const penpot: Penpot; + const penpotMgmt: PenpotMgmt; } diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 8956a5124e..a0ef062d3e 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -19,6 +19,7 @@ import type { Page, Path, Penpot, + PenpotMgmt, Rectangle, Shape, SvgRaw, @@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js'; export const validEvents = [ 'finish', + 'logout', + 'workspacechange', 'pagechange', 'filechange', 'selectionchange', @@ -47,10 +50,24 @@ export const validEvents = [ export function createApi( plugin: Awaited>, ) { - const checkPermission = (permission: Permissions) => { + const workspaceUnavailable = () => + plugin.manifest.scope === 'global' && + plugin.context.management?.workspace.status !== 'ready'; + + const requireWorkspace = () => { + if (workspaceUnavailable()) { + throw new Error( + 'No workspace is ready. Use penpotMgmt.openFile() first.', + ); + } + }; + + const checkPermission = (permission: Permissions, workspace = true) => { if (!plugin.manifest.permissions.includes(permission)) { throw new Error(`Permission ${permission} is not granted`); } + if (workspace && /^(content|library|comment):/.test(permission)) + requireWorkspace(); }; const penpot: Penpot = { @@ -162,7 +179,7 @@ export function createApi( z.function().parse(callback); // To suscribe to events needs the read permission - checkPermission('content:read'); + checkPermission('content:read', false); return plugin.registerListener(type, callback, props); }, @@ -178,18 +195,18 @@ export function createApi( }, get root(): Shape | null { - checkPermission('content:read'); - return plugin.context.root; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.root; }, get currentFile(): File | null { - checkPermission('content:read'); - return plugin.context.currentFile; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.currentFile; }, get currentPage(): Page | null { - checkPermission('content:read'); - return plugin.context.currentPage; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.currentPage; }, get selection(): Shape[] { @@ -203,10 +220,12 @@ export function createApi( }, get viewport(): Viewport { + requireWorkspace(); return plugin.context.viewport; }, get history(): HistoryContext { + requireWorkspace(); return plugin.context.history; }, @@ -395,7 +414,28 @@ export function createApi( }, }; + const penpotMgmt: PenpotMgmt = { + get workspace() { + checkPermission('content:read', false); + return plugin.context.management!.workspace; + }, + openFile(fileId, options) { + checkPermission('content:read', false); + return plugin.context.management!.openFile(fileId, options); + }, + on(type, callback) { + z.literal('workspacechange').parse(type); + z.function().parse(callback); + checkPermission('content:read', false); + return plugin.registerListener(type, callback); + }, + off(listenerId) { + plugin.destroyListener(listenerId); + }, + }; + return { penpot, + penpotMgmt, }; } diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts index 97cc4bb123..45af8b7621 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts @@ -33,6 +33,7 @@ describe('createSandbox', () => { beforeEach(() => { mockPlugin = { + manifest: { scope: 'workspace' }, code: 'console.log("Plugin running");', timeouts: new Set>(), } as unknown as Awaited>; diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts index 292ae49183..c8aaf3c40a 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts @@ -1,4 +1,3 @@ -import type { Penpot } from '@penpot/plugin-types'; import type { createPluginManager } from './plugin-manager'; import { createApi } from './api'; import { ses } from './ses.js'; @@ -67,7 +66,7 @@ export function createSandbox( const pluginApi = createApi(plugin); const safeHandler = { - get(target: Penpot, prop: string, receiver: unknown) { + get(target: object, prop: string, receiver: unknown) { const originalValue = Reflect.get(target, prop, receiver); if (typeof originalValue === 'function') { @@ -202,6 +201,12 @@ export function createSandbox( publicPluginApi = Object.assign(publicPluginApi, apiExtensions); } + if (plugin.manifest.scope === 'global') { + Object.assign(publicPluginApi, { + penpotMgmt: new Proxy(pluginApi.penpotMgmt, safeHandler), + }); + } + const compartment = ses.createCompartment(publicPluginApi); return { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts index 06ef7b20b7..4151e09fea 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts @@ -98,9 +98,14 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { ); // The plugin code really ran inside the sandbox: the manager registers - // `themechange` + `finish`, and the plugin code adds its own `finish` + // `themechange` + `finish` + `logout`, and the plugin code adds its own `finish` // listener through the public API. - expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']); + expect(fixture.listenerTypes).toEqual([ + 'themechange', + 'finish', + 'logout', + 'finish', + ]); expect(getPlugins()).toHaveLength(1); // The user-facing behavior from #11001: host-side augmentation of a @@ -112,7 +117,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(() => { freshWrapper.toString = () => 'patched-by-runtime'; }).not.toThrow(); - expect(fixture.listenerTypes.length).toBe(3); + expect(fixture.listenerTypes.length).toBe(4); }); it('denies the write API without permission and leaves the host untouched', async () => { @@ -166,4 +171,79 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(fixture.createRectangle).toHaveBeenCalledTimes(1); expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true); }); + it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => { + const fixture = makeHostFixture(); + let workspace = { + status: 'none', + fileId: null, + fileName: null, + teamId: null, + }; + const listeners = new Map< + symbol, + { type: string; callback: (value?: unknown) => void } + >(); + const context = { + ...fixture.context, + management: { + get workspace() { + return workspace; + }, + openFile: async () => {}, + }, + addListener(type: string, callback: (value?: unknown) => void) { + const id = Symbol(); + listeners.set(id, { type, callback }); + return id; + }, + removeListener(id: symbol) { + listeners.delete(id); + }, + } as unknown as Context; + setContextBuilder(() => context); + const manifest = { + ...makeManifest( + 'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;', + ['content:read', 'content:write'], + ), + pluginId: 'global-plugin', + scope: 'global' as const, + }; + await loadPlugin(manifest); + const globalPlugin = getPlugins().find( + (plugin) => plugin.manifest.pluginId === 'global-plugin', + )!; + const globals = globalPlugin.compartment.compartment.globalThis; + expect(globals['probe']).toBe('none'); + expect(() => + globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'), + ).toThrow(/No workspace/); + for (const listener of [...listeners.values()]) { + if (listener.type === 'finish') listener.callback(); + } + await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', [])); + expect(getPlugins()).toContain(globalPlugin); + await loadPlugin(manifest); + expect( + getPlugins().filter( + (plugin) => plugin.manifest.pluginId === 'global-plugin', + ), + ).toHaveLength(1); + workspace = { ...workspace, status: 'ready' }; + globalPlugin.compartment.compartment.evaluate( + 'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;', + ); + expect(globals['sameFacade']).toBe(true); + expect(fixture.createRectangle).toHaveBeenCalledOnce(); + await expect( + loadPlugin(makeManifest('throw new Error("failure");', [])), + ).rejects.toThrow('failure'); + expect(getPlugins()).toContain(globalPlugin); + for (const listener of [...listeners.values()]) { + if (listener.type === 'logout') listener.callback(); + } + expect(getPlugins()).toHaveLength(0); + expect(globals['penpotMgmt']).toBeUndefined(); + expect(listeners.size).toBe(0); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index e22e62c70c..be332d9058 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -17,20 +17,19 @@ export function setContextBuilder(builder: ContextBuilder) { export const getPlugins = () => plugins; const closeAllPlugins = () => { - // Background plugins keep running, so they must stay registered: the - // registry routes their UI messages and lets them be unloaded later. - const backgroundPlugins: typeof plugins = []; - - plugins.forEach((pluginApi) => { + // Background and global plugins keep running, so they must stay registered: + // the registry routes their UI messages and lets them be unloaded later. + plugins = plugins.filter((pluginApi) => { /* eslint-disable @typescript-eslint/no-explicit-any */ - if ((pluginApi.manifest as any)?.allowBackground) { - backgroundPlugins.push(pluginApi); - } else { - pluginApi.plugin.close(); + if ( + pluginApi.manifest?.scope === 'global' || + (pluginApi.manifest as any)?.allowBackground + ) { + return true; } + pluginApi.plugin.close(); + return false; }); - - plugins = backgroundPlugins; }; window.addEventListener('message', (event) => { @@ -57,7 +56,17 @@ export const loadPlugin = async function ( return; } - closeAllPlugins(); + if (manifest.scope === 'global') { + if ( + plugins.some( + (plugin) => plugin.manifest?.pluginId === manifest.pluginId, + ) + ) { + return; + } + } else { + closeAllPlugins(); + } // The host context is not deeply frozen at this load stage. // @@ -74,7 +83,8 @@ export const loadPlugin = async function ( // `createSandbox`'s proxy handler applies `ses.safeReturn` to values // crossing into the sandbox. Compartment isolation and intrinsics // hardening are performed by createSandbox, not here. - const plugin = await createPlugin( + let plugin: Awaited> | undefined = undefined; + plugin = await createPlugin( context, manifest, () => { @@ -88,7 +98,7 @@ export const loadPlugin = async function ( ); plugins.push(plugin); } catch (error) { - closeAllPlugins(); + if (manifest.scope !== 'global') closeAllPlugins(); throw error; } }; diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index 16d4fd5e28..aefc563c65 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -7,6 +7,7 @@ export const manifestSchema = z.object({ code: z.string(), icon: z.string().optional(), version: z.number().optional(), + scope: z.enum(['workspace', 'global']).optional(), description: z.string().max(200).optional(), permissions: z.array( z.enum([ diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 7878a1abcd..f430321210 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -40,11 +40,10 @@ export async function createPluginManager( modal?.setTheme(theme); }); - const listenerId: symbol = context.addListener('finish', () => { - closePlugin(); - - context?.removeListener(listenerId); + const listenerId = context.addListener('finish', () => { + if (manifest.scope !== 'global') closePlugin(); }); + const logoutId = context.addListener('logout', () => closePlugin()); let listeners: symbol[] = []; @@ -60,7 +59,11 @@ export async function createPluginManager( }; const closePlugin = () => { + if (destroyed) return; + destroyed = true; removeAllEventListeners(); + destroyListener(listenerId); + destroyListener(logoutId); timeouts.forEach(clearTimeout); timeouts.clear(); @@ -74,8 +77,6 @@ export async function createPluginManager( modal = null; } - destroyed = true; - onCloseCallback(); };