From be17fabc6a5a013efc749f0bf44942854ab10c3c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 12:20:51 +0000 Subject: [PATCH 01/10] :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(); }; From 54cce66c13e569a5870d6bb922b6e6a09bf2a267 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 13:19:11 +0000 Subject: [PATCH 02/10] :sparkles: Share the MCP connection menu with the dashboard Connection status: - Replace the dashboard's action-only link with an explicit connected label and indicator, using the theme's existing accent colour. - Reflect the actual connection state independently of connection intent. Shared controls: - Extract the toolbar's MCP menu and styles into a shared component used by both the dashboard and workspace. - Provide connection status, session ID, copy, connect and disconnect actions in both locations, preserving hover and keyboard behaviour. - Keep dashboard and toolbar event origins distinct and update the dashboard connection instructions. Validation: - Confirm dashboard disconnect/reconnect, menu opening, keyboard navigation and Escape dismissal in the running app. - Pass live compilation, targeted ClojureScript and SCSS lint, formatting checks and git diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- docs/mcp/index.md | 2 +- .../src/app/main/ui/components/mcp_menu.cljs | 141 ++++++++++++++++++ .../src/app/main/ui/components/mcp_menu.scss | 127 ++++++++++++++++ .../src/app/main/ui/dashboard/sidebar.cljs | 22 +-- .../app/main/ui/workspace/top_toolbar.cljs | 120 +-------------- .../app/main/ui/workspace/top_toolbar.scss | 106 ------------- 6 files changed, 276 insertions(+), 242 deletions(-) create mode 100644 frontend/src/app/main/ui/components/mcp_menu.cljs create mode 100644 frontend/src/app/main/ui/components/mcp_menu.scss diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 47a4c55803..cce812b516 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -81,7 +81,7 @@ Some important concepts for users: Connect the tabs you want to use: -* **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. +* **Remote MCP**: choose **MCP → Connect** in the dashboard sidebar or 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. diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs new file mode 100644 index 0000000000..f38e4f377b --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.cljs @@ -0,0 +1,141 @@ +;; 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.main.ui.components.mcp-menu + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.workspace.mcp :as mcp] + [app.main.store :as st] + [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [rumext.v2 :as mf])) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc mcp-menu* + {::mf/wrap [mf/memo]} + [{:keys [is-mcp-connected is-connection-requested session-id dashboard]}] + (let [copied-text (tr "workspace.toolbar.mcp-session-copied") + copy-error (tr "errors.clipboard-api-unavailable") + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + on-open-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* true))) + + on-close-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* false))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! menu-open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! menu-open* false) + (mf/set-ref-val! close-timer* nil)))))) + + on-connect + (mf/use-fn + (mf/deps dashboard) + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin (if dashboard "dashboard:sidebar" "workspace:toolbar")}))) + + on-disconnect + (mf/use-fn + #(st/emit! (mcp/user-disconnect-mcp))) + + on-copy-session + (mf/use-fn + (mf/deps session-id copied-text copy-error) + (fn [] + (-> (clipboard/to-clipboard session-id) + (.then #(st/emit! (ntf/info copied-text))) + (.catch #(st/emit! (ntf/error copy-error))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:class (stl/css-case :mcp-tool true :dashboard dashboard) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:> button* {:variant "ghost" + :on-click on-open-menu + :aria-haspopup true + :aria-expanded menu-open? + :aria-pressed menu-open? + :data-tool "mcp" + :data-testid "mcp-btn"} + [:div {:class (stl/css-case :toolbar-mcp-button true + :selected menu-open?)} + [:span {:class (stl/css-case :toolbar-mcp-button-dot true + :connected is-mcp-connected)}] + [:span {:class (stl/css-case :toolbar-mcp-button-label true + :connected is-mcp-connected)} + (if (and dashboard is-mcp-connected) + (tr "workspace.toolbar.mcp-connected") + (tr "workspace.toolbar.mcp"))]]] + + [:div {:class (stl/css :toolbar-mcp-menu)} + [:> dropdown-menu* {:show menu-open? + :on-close on-close-menu + :class (stl/css :toolbar-mcp-dropdown)} + (when (or is-mcp-connected session-id) + [:li {:class (stl/css :toolbar-mcp-dropdown-info) + :role "presentation"} + (when is-mcp-connected + [:span (tr "workspace.toolbar.mcp-connected")]) + (when session-id + [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) + (when session-id + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click on-copy-session} + (tr "workspace.toolbar.mcp-copy-session-id")]) + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click (if is-connection-requested on-disconnect on-connect)} + (if is-connection-requested + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss new file mode 100644 index 0000000000..f4c69ba4c8 --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.scss @@ -0,0 +1,127 @@ +// 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 + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; + +.mcp-tool { + position: relative; +} + +.dashboard { + margin-inline: var(--sp-l); +} + +.toolbar-mcp-button { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.toolbar-mcp-button-label { + &.connected { + color: var(--color-accent-primary); + } +} + +.toolbar-mcp-button-dot { + position: relative; + display: flex; + flex-shrink: 0; + inline-size: px2rem(6); + block-size: px2rem(6); + border-radius: 50%; + background-color: var(--color-background-disabled); + + &.connected { + background-color: var(--color-accent-primary); + } + + // One-shot "blob" ripple that confirms the moment the tab becomes + // connected (e.g. after using "Switch here"). Triggered purely by the + // `.connected` class appearing, in sync with the color change. + &.connected::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--color-accent-primary); + opacity: 0; + animation: mcp-status-blob 0.5s ease-out; + } +} + +@keyframes mcp-status-blob { + from { + opacity: 0.5; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(2.5); + } +} + +.toolbar-mcp-menu { + position: absolute; + left: 0; + top: $sz-36; +} + +// Same look as the workspace main menu (`.base-menu` in main_menu.scss). +// The toolbar overrides `--menu-background-color`, so restore the global +// menu value here. +.toolbar-mcp-dropdown { + --menu-background-color: var(--color-background-tertiary); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + z-index: var(--z-index-dropdown); + margin: 0; + padding: var(--sp-xs); + border: $b-2 solid var(--panel-border-color); + border-radius: $br-8; + background-color: var(--menu-background-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} + +.toolbar-mcp-dropdown-info { + @include t.use-typography("body-small"); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); + font-size: px2rem(11); + white-space: nowrap; +} + +.toolbar-mcp-dropdown-item { + @include t.use-typography("body-small"); + + cursor: pointer; + display: flex; + align-items: center; + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + white-space: nowrap; + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + } +} diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 386d1dc791..8787c290ca 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -17,13 +17,13 @@ [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] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -257,12 +257,6 @@ 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) @@ -394,16 +388,10 @@ (: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"))]]]]]) + [:> mcp-menu* {:dashboard true + :is-mcp-connected (= "connected" (:connection-status mcp-state)) + :is-connection-requested (:connection-requested mcp-state) + :session-id (:session-id mcp-state)}]]) [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 0a10cf095d..7a7f767621 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -12,24 +12,20 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.drawing.common :as dwdc] - [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path.state :as pst] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.file-uploader :as file-uploader] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.context :as ctx] - [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -297,118 +293,6 @@ :ref ref :on-selected on-selected}]])) -(mf/defc mcp-tool* - {::mf/private true - ::mf/wrap [mf/memo]} - [{:keys [is-mcp-connected is-connection-requested session-id]}] - (let [copied-text (tr "workspace.toolbar.mcp-session-copied") - copy-error (tr "errors.clipboard-api-unavailable") - menu-open* (mf/use-state false) - menu-open? (deref menu-open*) - - open-timer* (mf/use-ref nil) - close-timer* (mf/use-ref nil) - - on-open-menu - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* true))) - - on-close-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* false))) - - on-display-menu - (mf/use-fn - (fn [] - (cancel-timer! close-timer*) - (cancel-timer! open-timer*) - (mf/set-ref-val! - open-timer* - (ts/schedule 350 - #(do - (reset! menu-open* true) - (mf/set-ref-val! open-timer* nil)))))) - - on-hide-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (mf/set-ref-val! - close-timer* - (ts/schedule 350 - #(do - (reset! menu-open* false) - (mf/set-ref-val! close-timer* nil)))))) - - on-connect - (mf/use-fn - #(st/emit! (mcp/connect-mcp) - (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"}))) - - on-disconnect - (mf/use-fn - #(st/emit! (mcp/user-disconnect-mcp))) - - on-copy-session - (mf/use-fn - (mf/deps session-id copied-text copy-error) - (fn [] - (-> (clipboard/to-clipboard session-id) - (.then #(st/emit! (ntf/info copied-text))) - (.catch #(st/emit! (ntf/error copy-error))))))] - - (mf/with-effect [] - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*))) - - [:div {:on-pointer-enter on-display-menu - :on-pointer-leave on-hide-menu} - [:> button* {:variant "ghost" - :on-click on-open-menu - :aria-haspopup true - :aria-expanded menu-open? - :aria-pressed menu-open? - :data-tool "mcp" - :data-testid "mcp-btn"} - [:div {:class (stl/css-case :toolbar-mcp-button true - :selected menu-open?)} - [:span {:class (stl/css-case :toolbar-mcp-button-dot true - :connected is-mcp-connected)}] - [:span {:class (stl/css-case :toolbar-mcp-button-label true - :connected is-mcp-connected)} - (tr "workspace.toolbar.mcp")]]] - - [:div {:class (stl/css :toolbar-mcp-menu)} - [:> dropdown-menu* {:show menu-open? - :on-close on-close-menu - :class (stl/css :toolbar-mcp-dropdown)} - (when (or is-mcp-connected session-id) - [:li {:class (stl/css :toolbar-mcp-dropdown-info) - :role "presentation"} - (when is-mcp-connected - [:span (tr "workspace.toolbar.mcp-connected")]) - (when session-id - [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) - (when session-id - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click on-copy-session} - (tr "workspace.toolbar.mcp-copy-session-id")]) - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click (if is-connection-requested on-disconnect on-connect)} - (if is-connection-requested - (tr "workspace.header.menu.mcp.plugin.status.disconnect") - (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) - (mf/defc top-toolbar* {::mf/wrap [mf/memo]} [{:keys [layout]}] @@ -545,7 +429,7 @@ (when mcp-show? [:li {:class (stl/css :toolbar-option)} - [:> mcp-tool* {:is-mcp-connected mcp-connected? + [:> mcp-menu* {:is-mcp-connected mcp-connected? :is-connection-requested (:connection-requested mcp) :session-id (:session-id mcp)}]])] diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 7f5637c8f5..71ad771b3c 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -101,112 +101,6 @@ } } -.toolbar-mcp-button { - display: flex; - align-items: center; - gap: var(--sp-xs); -} - -.toolbar-mcp-button-label { - &.connected { - color: var(--color-accent-primary); - } -} - -.toolbar-mcp-button-dot { - position: relative; - display: flex; - flex-shrink: 0; - inline-size: px2rem(6); - block-size: px2rem(6); - border-radius: 50%; - background-color: var(--color-background-disabled); - - &.connected { - background-color: var(--color-accent-primary); - } - - // One-shot "blob" ripple that confirms the moment the tab becomes - // connected (e.g. after using "Switch here"). Triggered purely by the - // `.connected` class appearing, in sync with the color change. - &.connected::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background-color: var(--color-accent-primary); - opacity: 0; - animation: mcp-status-blob 0.5s ease-out; - } -} - -@keyframes mcp-status-blob { - from { - opacity: 0.5; - transform: scale(1); - } - - to { - opacity: 0; - transform: scale(2.5); - } -} - -.toolbar-mcp-menu { - position: absolute; - left: 0; - top: $sz-36; -} - -// Same look as the workspace main menu (`.base-menu` in main_menu.scss). -// The toolbar overrides `--menu-background-color`, so restore the global -// menu value here. -.toolbar-mcp-dropdown { - --menu-background-color: var(--color-background-tertiary); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - z-index: var(--z-index-dropdown); - margin: 0; - padding: var(--sp-xs); - border: $b-2 solid var(--panel-border-color); - border-radius: $br-8; - background-color: var(--menu-background-color); - box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); -} - -.toolbar-mcp-dropdown-info { - @include t.use-typography("body-small"); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--color-foreground-secondary); - background-color: var(--color-background-primary); - font-size: px2rem(11); - white-space: nowrap; -} - -.toolbar-mcp-dropdown-item { - @include t.use-typography("body-small"); - - display: flex; - align-items: center; - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--menu-foreground-color); - background-color: var(--menu-background-color); - white-space: nowrap; - - &:hover { - --menu-foreground-color: var(--menu-foreground-color-hover); - --menu-background-color: var(--menu-background-color-hover); - } -} - .toolbar-separator { --separator-color: var(--color-background-quaternary); From 2bb6cd557eff7b2c457a96a42e43da7d9ee4f8c3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 14:32:56 +0000 Subject: [PATCH 03/10] :sparkles: Add project and file discovery to penpotMgmt Discovery: - Add listProjects and project-scoped listFiles with content:read checks. - Return metadata from existing RPCs in dashboard and workspace contexts. - Reject invalid IDs and preserve backend access errors. Documentation: - Keep usage in API type comments and include regenerated MCP API docs. - Remove README implementation details and consolidate the changelog. - Add usage information to the MCP server's initial instructions Validation: - Pass 8 frontend tests, 81 runtime tests and the runtime build. - Verify both APIs through live MCP calls in dashboard and workspace. - Pass type, lint, formatting and diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- frontend/src/app/plugins/management.cljs | 48 ++- .../plugins/management_test.cljs | 71 +++++ mcp/README.md | 10 - mcp/packages/server/data/api_types.yml | 273 +++++++++++++++++- .../server/data/initial_instructions.md | 10 +- .../server/src/tools/ExecuteCodeTool.ts | 5 +- plugins/CHANGELOG.md | 3 +- plugins/libs/plugin-types/index.d.ts | 73 ++++- .../libs/plugins-runtime/src/lib/api/index.ts | 8 + .../src/lib/api/plugin-api.spec.ts | 54 ++++ 10 files changed, 532 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index bff5d9292d..9dadac543b 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -8,9 +8,11 @@ (:require [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [beicon.v2.core :as rx])) (defn workspace-context [state] @@ -70,9 +72,51 @@ (finish (js/Error. "File navigation was superseded")))))) (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) +(defn list-projects + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (if (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (clj->js + (mapv (fn [project] + {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (:count project)}) + (remove :deleted-at projects))))) + (rx/subs! resolve reject))))))) + +(defn list-files + [options] + (let [project-id (uuid/parse* (obj/get options "projectId"))] + (if (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-project-files {:project-id project-id}) + (rx/map (fn [files] + (clj->js + (mapv (fn [file] + {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) + files)))) + (rx/subs! resolve reject))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} :workspace {:get (fn [] (clj->js (workspace-context @st/state)))} - :openFile open-file)) + :openFile open-file + :listProjects list-projects + :listFiles list-files)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 1e11a83367..9db0ae80c0 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -7,9 +7,11 @@ (ns frontend-tests.plugins.management-test (:require [app.common.uuid :as uuid] + [app.main.repo :as rp] [app.main.store :as st] [app.plugins.events :as events] [app.plugins.management :as management] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] [frontend-tests.helpers.mock :as mock])) @@ -81,3 +83,72 @@ (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))))) + +(t/deftest ^:async list-projects-returns-active-metadata + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[state options expected-team-id] + [[dashboard nil team-id] + [ready #js {:teamId (str other-team-id)} other-team-id]]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-projects {:team-id expected-team-id}] + [command params])) + (rx/of [{:id project-id :team-id expected-team-id + :name "Drafts" :is-default true :count 2 :total-count 3} + {:id (uuid/next) :deleted-at (js/Date.)}])))} + (let [projects (await (management/list-projects options))] + (t/is (= [{"id" (str project-id) "teamId" (str expected-team-id) + "name" "Drafts" "isDefault" true "fileCount" 2}] + (js->clj projects))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async list-files-returns-metadata-without-opening-a-file + (let [project-id (uuid/next)] + (doseq [state [dashboard ready]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-project-files {:project-id project-id}] + [command params])) + (rx/of [{:id file-id :team-id team-id :project-id project-id + :name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z") + :revn 10}])))} + (let [files (await (management/list-files #js {:projectId (str project-id)}))] + (t/is (= [{"id" (str file-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Design" + "modifiedAt" "2026-01-02T03:04:05.000Z"}] + (js->clj files))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying + (await + (mock/with-mocks* + {st/state (atom {}) + rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))} + (doseq [[result message] + [[(management/list-projects nil) "Expected a team UUID"] + [(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"] + [(management/list-files nil) "Expected a project UUID"] + [(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]] + (t/is (= message (await (.then result (fn [] nil) #(.-message %))))))))) + +(t/deftest ^:async listing-preserves-empty-results-and-backend-errors + (doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)})) + (fn [] (management/list-files #js {:projectId (str (uuid/next))}))]] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of [])))} + (t/is (= [] (js->clj (await (list-items))))))) + (let [error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} + (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) diff --git a/mcp/README.md b/mcp/README.md index 7018537793..59d60aaf61 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -33,16 +33,6 @@ 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) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index e0ebcde698..c926147938 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -92,7 +92,7 @@ Penpot: Hierarchy - * Omit + * Omit + Penpot members: Properties: @@ -5001,6 +5001,250 @@ CommonLayout: The `remove` method removes the layout. + Returns void +WorkspaceContext: + overview: |- + Interface WorkspaceContext + ========================== + + Current workspace of this browser tab. + + ``` + interface WorkspaceContext { + status: "none" | "loading" | "ready"; + fileId: string | null; + fileName: string | null; + teamId: string | null; + } + ``` + + Referenced by: EventsMap, PenpotMgmt + members: + Properties: + status: |- + ``` + readonly status: "none" | "loading" | "ready" + ``` + fileId: |- + ``` + readonly fileId: string | null + ``` + fileName: |- + ``` + readonly fileName: string | null + ``` + teamId: |- + ``` + readonly teamId: string | null + ``` +ProjectSummary: + overview: |- + Interface ProjectSummary + ======================== + + Metadata snapshot of an accessible, non-deleted project. + + ``` + interface ProjectSummary { + id: string; + teamId: string; + name: string; + isDefault: boolean; + fileCount: number; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + name: |- + ``` + readonly name: string + ``` + isDefault: |- + ``` + readonly isDefault: boolean + ``` + + Whether this is the team's Drafts project. + fileCount: |- + ``` + readonly fileCount: number + ``` +FileSummary: + overview: |- + Interface FileSummary + ===================== + + Metadata snapshot of an accessible, non-deleted file. + + ``` + interface FileSummary { + id: string; + teamId: string; + projectId: string; + name: string; + modifiedAt: string; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + projectId: |- + ``` + readonly projectId: string + ``` + name: |- + ``` + readonly name: string + ``` + modifiedAt: |- + ``` + readonly modifiedAt: string + ``` + + Last modification time as an ISO 8601 string in UTC. +PenpotMgmt: + overview: |- + Interface PenpotMgmt + ==================== + + Application management API exposed as `penpotMgmt` to global plugins. + + * Requires `content:read` permission. + * Available in the dashboard and file workspaces. + * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Reacquire file, page and shape objects after changing files. + + ``` + interface PenpotMgmt { + workspace: WorkspaceContext; + listProjects(options?: { teamId?: string }): Promise; + listFiles(options: { projectId: string }): Promise; + openFile(fileId: string, options?: { teamId?: string }): Promise; + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol; + off(listenerId: symbol): void; + } + ``` + + Referenced by: Context + members: + Properties: + workspace: |- + ``` + readonly workspace: WorkspaceContext + ``` + + Current workspace availability and metadata; usable for file operations when status is `ready`. + Methods: + listProjects: |- + ``` + listProjects(options?: { teamId?: string }): Promise + ``` + + Lists accessible, non-deleted projects as metadata snapshots. + + * `teamId` defaults to the current team. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { teamId?: string } + + Returns Promise + + Example + ``` + const projects = await penpotMgmt.listProjects(); + ``` + listFiles: |- + ``` + listFiles(options: { projectId: string }): Promise + ``` + + Lists accessible, non-deleted files in the specified project as metadata snapshots. + + * Requires an explicit `projectId`. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { projectId: string } + + Returns Promise + + Example + ``` + const files = await penpotMgmt.listFiles({ projectId: project.id }); + ``` + openFile: |- + ``` + openFile(fileId: string, options?: { teamId?: string }): Promise + ``` + + Opens a file in this tab and resolves once its workspace and page are ready. + + * `teamId` defaults to the current team. + * Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + + Parameters + + * fileId: string + * options: { teamId?: string } + + Returns Promise + + Example + ``` + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` + on: |- + ``` + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol + ``` + + Subscribes to workspace availability and metadata changes. + + Parameters + + * type: "workspacechange" + * callback: (workspace: WorkspaceContext) => void + + Returns symbol + off: |- + ``` + off(listenerId: symbol): void + ``` + + Parameters + + * listenerId: symbol + Returns void Context: overview: |- @@ -5011,6 +5255,13 @@ Context: ``` interface Context { + management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + >; version: string; root: Shape | null; currentFile: File | null; @@ -5082,6 +5333,16 @@ Context: ``` members: Properties: + management: |- + ``` + readonly management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + > + ``` version: |- ``` readonly version: string @@ -7109,6 +7370,8 @@ EventsMap: ``` interface EventsMap { + workspacechange: WorkspaceContext; + logout: void; pagechange: Page; filechange: File; selectionchange: string[]; @@ -7122,6 +7385,14 @@ EventsMap: Referenced by: Context, Penpot members: Properties: + workspacechange: |- + ``` + readonly workspacechange: WorkspaceContext + ``` + logout: |- + ``` + readonly logout: void + ``` pagechange: |- ``` readonly pagechange: Page diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index be9177619c..ea5961c605 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -166,13 +166,15 @@ Boards can have layout systems that automatically control the positioning and sp # The `penpot` and `penpotUtils` Objects, Exploring Designs -A key object to use in your code is the `penpot` object (which is of type `Penpot`): +A key object for navigating the currently active Penpot file is the `penpot` object (which is of type `Penpot`): * `penpot.selection` provides the list of shapes the user has selected in the Penpot UI. If it is unclear which elements to work on, you can ask the user to select them for you. ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged. * `penpot.root` provides the root shape of the currently active page. * Generation of CSS content for elements via `penpot.generateStyle` * Generation of HTML/SVG content for elements via `penpot.generateMarkup` +Members of `penpot` (e.g. `penpot.currentFile` and `penpot.root`) will be null no Penpot file is currently opened. +You can use `penpotMgmt` to navigate to a file if needed (see below). For example, to generate CSS for the currently selected elements, you can execute this: return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true }); @@ -430,5 +432,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u * The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. Consider converting boards to flex layout when appropriate. +# The `penpotMgmt` Object + +This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. +It allows you to discover and navigate to different design files the user has access to. +You navigate to different design files only when your task explicitly requires it/you are instructed to do so. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 7f913a5700..7a8e710e33 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -49,10 +49,7 @@ 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 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" + + "You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\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 8ff05e595b..6dabccbb2f 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,8 +2,7 @@ ### 🚀 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. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **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 0ef43fdd4d..d1d145fe96 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -786,10 +786,74 @@ export interface WorkspaceContext { readonly teamId: string | null; } -/** Application management API available to global plugins. */ +/** Metadata snapshot of an accessible, non-deleted project. */ +export interface ProjectSummary { + readonly id: string; + readonly teamId: string; + readonly name: string; + /** Whether this is the team's Drafts project. */ + readonly isDefault: boolean; + readonly fileCount: number; +} + +/** Metadata snapshot of an accessible, non-deleted file. */ +export interface FileSummary { + readonly id: string; + readonly teamId: string; + readonly projectId: string; + readonly name: string; + /** Last modification time as an ISO 8601 string in UTC. */ + readonly modifiedAt: string; +} + +/** + * Application management API exposed as `penpotMgmt` to global plugins. + * + * - Requires `content:read` permission. + * - Available in the dashboard and file workspaces. + * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Reacquire file, page and shape objects after changing files. + */ export interface PenpotMgmt { + /** Current workspace availability and metadata; usable for file operations when status is `ready`. */ readonly workspace: WorkspaceContext; - /** Opens a file in this tab and resolves once its workspace and page are ready. */ + /** + * Lists accessible, non-deleted projects as metadata snapshots. + * + * - `teamId` defaults to the current team. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const projects = await penpotMgmt.listProjects(); + * ``` + */ + listProjects(options?: { teamId?: string }): Promise; + /** + * Lists accessible, non-deleted files in the specified project as metadata snapshots. + * + * - Requires an explicit `projectId`. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const files = await penpotMgmt.listFiles({ projectId: project.id }); + * ``` + */ + listFiles(options: { projectId: string }): Promise; + /** + * Opens a file in this tab and resolves once its workspace and page are ready. + * + * - `teamId` defaults to the current team. + * - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + * + * @example + * ```ts + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ openFile(fileId: string, options?: { teamId?: string }): Promise; /** Subscribes to workspace availability and metadata changes. */ on( @@ -803,7 +867,10 @@ export interface PenpotMgmt { * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { - readonly management?: Pick; + readonly management?: Pick< + PenpotMgmt, + 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + >; /** * Returns the current penpot version. */ diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index a0ef062d3e..ba36b1e12a 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -419,6 +419,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.workspace; }, + listProjects(options) { + checkPermission('content:read', false); + return plugin.context.management!.listProjects(options); + }, + listFiles(options) { + checkPermission('content:read', false); + return plugin.context.management!.listFiles(options); + }, openFile(fileId, options) { checkPermission('content:read', false); return plugin.context.management!.openFile(fileId, options); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 308fb81245..3513b97acd 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -54,6 +54,60 @@ describe('Plugin api', () => { vi.clearAllMocks(); }); + describe('management listing', () => { + it.each(['none', 'loading', 'ready'])( + 'lists metadata with workspace status %s', + async (status) => { + const projects = [{ id: 'project-id' }]; + const files = [{ id: 'file-id' }]; + const management = { + workspace: { status }, + listProjects: vi.fn().mockResolvedValue(projects), + listFiles: vi.fn().mockResolvedValue(files), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + await expect(penpotMgmt.listProjects()).resolves.toEqual(projects); + expect(management.listProjects).toHaveBeenLastCalledWith(undefined); + await penpotMgmt.listProjects({ teamId: 'team-id' }); + expect(management.listProjects).toHaveBeenLastCalledWith({ + teamId: 'team-id', + }); + await expect( + penpotMgmt.listFiles({ projectId: 'project-id' }), + ).resolves.toEqual(files); + expect(management.listFiles).toHaveBeenCalledWith({ + projectId: 'project-id', + }); + }, + ); + + it('requires content:read before querying metadata', () => { + const management = { + listProjects: vi.fn(), + listFiles: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: [], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.listProjects()).toThrow(); + expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow(); + expect(management.listProjects).not.toHaveBeenCalled(); + expect(management.listFiles).not.toHaveBeenCalled(); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({ From 036f59266bca54831fb565f98024bb7f97889490 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 17:58:52 +0000 Subject: [PATCH 04/10] :bug: Discard cancelled plugin loads after unloading Cause: - Disabling MCP during script loading could leave a late plugin instance attached to a stopped controller, blocking connection after re-enabling. Fix: - Track pending loads and discard instances cancelled by unload. - Prevent duplicate global loads and preserve newer replacement instances. - Clear pending state after failures so a subsequent load can retry. Validation: - Pass 84 runtime tests, the runtime build, lint and formatting checks. - Reproduce the race with a delayed script in the browser, then verify re-enabling restores the session ID, themed indicator and MCP execution. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- plugins/CHANGELOG.md | 2 +- .../src/lib/load-plugin-real-path.spec.ts | 73 ++++++++++++++++++- .../plugins-runtime/src/lib/load-plugin.ts | 16 +++- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 6dabccbb2f..94cd3f268c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **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/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts index 4151e09fea..2200f434b1 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 @@ -1,6 +1,11 @@ import { describe, it, vi, expect, beforeAll } from 'vitest'; import 'ses'; -import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin'; +import { + loadPlugin, + setContextBuilder, + getPlugins, + ɵunloadPlugin, +} from './load-plugin'; import type { Context } from '@penpot/plugin-types'; import type { Manifest } from './models/manifest.model.js'; @@ -69,7 +74,7 @@ function makeHostFixture() { // public penpot API. Plugin code must never see it (see B-2 below). __internalSecret: 'host-internal', } as unknown as Context; - return { context, listenerTypes, createRectangle, selection }; + return { context, listeners, listenerTypes, createRectangle, selection }; } function lastCompartmentGlobalThis(): Record { @@ -171,6 +176,70 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(fixture.createRectangle).toHaveBeenCalledTimes(1); expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true); }); + it.each(['before', 'after'])( + 'discards a cancelled global load that completes %s its replacement', + async (order) => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + host: 'https://plugins.test/', + scope: 'global' as const, + }; + ɵunloadPlugin(manifest.pluginId); + let finishFetch!: (response: object) => void; + const response = new Promise((resolve) => { + finishFetch = resolve; + }); + const fetch = vi.fn().mockReturnValue(response); + vi.stubGlobal('fetch', fetch); + try { + const cancelled = loadPlugin(manifest); + await loadPlugin(manifest); + expect(fetch).toHaveBeenCalledOnce(); + ɵunloadPlugin(manifest.pluginId); + const replacement = () => + loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + if (order === 'after') await replacement(); + finishFetch({ + ok: true, + text: async () => 'globalThis.marker = "cancelled";', + }); + await cancelled; + if (order === 'before') { + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await replacement(); + } + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }, + ); + + it('allows retrying a global plugin after a failed load', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('throw new Error("load failed");', []), + scope: 'global' as const, + }; + await expect(loadPlugin(manifest)).rejects.toThrow('load failed'); + expect(getPlugins()).toHaveLength(0); + await loadPlugin({ ...manifest, code: 'globalThis.marker = "retry";' }); + expect(lastCompartmentGlobalThis()['marker']).toBe('retry'); + ɵunloadPlugin(manifest.pluginId); + expect(fixture.listeners.size).toBe(0); + }); + it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => { const fixture = makeHostFixture(); let workspace = { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index be332d9058..bad0d4fd63 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -5,6 +5,7 @@ import { Manifest } from './models/manifest.model.js'; import { createPlugin } from './create-plugin.js'; let plugins: Awaited>[] = []; +const pendingPlugins = new Map(); export type ContextBuilder = (id: string) => Context; @@ -49,6 +50,7 @@ export const loadPlugin = async function ( closeCallback?: () => void, apiExtensions?: object, ) { + const loadId = Symbol(); try { const context = contextBuilder && contextBuilder(manifest.pluginId); @@ -58,6 +60,7 @@ export const loadPlugin = async function ( if (manifest.scope === 'global') { if ( + pendingPlugins.has(manifest.pluginId) || plugins.some( (plugin) => plugin.manifest?.pluginId === manifest.pluginId, ) @@ -83,7 +86,9 @@ 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. - let plugin: Awaited> | undefined = undefined; + pendingPlugins.set(manifest.pluginId, loadId); + let plugin: Awaited> | undefined = + undefined; plugin = await createPlugin( context, manifest, @@ -96,10 +101,18 @@ export const loadPlugin = async function ( }, apiExtensions, ); + if (pendingPlugins.get(manifest.pluginId) !== loadId) { + plugin.plugin.close(); + return; + } plugins.push(plugin); } catch (error) { if (manifest.scope !== 'global') closeAllPlugins(); throw error; + } finally { + if (pendingPlugins.get(manifest.pluginId) === loadId) { + pendingPlugins.delete(manifest.pluginId); + } } }; @@ -117,6 +130,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) { }; export const ɵunloadPlugin = function (id: Manifest['pluginId']) { + pendingPlugins.delete(id); const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id); if (plugin) { From c4d2ca9cd10fba6635f6d51e66b95b22539ab318 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:23:29 +0200 Subject: [PATCH 05/10] :sparkles: Move the dashboard MCP menu to the page headers Show the MCP menu next to the layout toggle in the header of the projects, files and deleted pages, instead of in the sidebar. It uses the secondary button style there and a visible dot colour while disconnected. Clicking the MCP button now toggles the menu instead of only opening it. Dashboard events report "dashboard:header" as their origin. --- docs/mcp/index.md | 2 +- .../src/app/main/ui/components/mcp_menu.cljs | 27 +++++++++++++------ .../src/app/main/ui/components/mcp_menu.scss | 10 +++---- .../src/app/main/ui/dashboard/deleted.cljs | 2 ++ frontend/src/app/main/ui/dashboard/files.cljs | 2 ++ .../src/app/main/ui/dashboard/projects.cljs | 2 ++ .../src/app/main/ui/dashboard/sidebar.cljs | 11 -------- 7 files changed, 31 insertions(+), 25 deletions(-) diff --git a/docs/mcp/index.md b/docs/mcp/index.md index cce812b516..fb9c927106 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -81,7 +81,7 @@ Some important concepts for users: Connect the tabs you want to use: -* **Remote MCP**: choose **MCP → Connect** in the dashboard sidebar or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. +* **Remote MCP**: choose **MCP → Connect** in the dashboard header or 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. diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs index f38e4f377b..8e5091bfd3 100644 --- a/frontend/src/app/main/ui/components/mcp_menu.cljs +++ b/frontend/src/app/main/ui/components/mcp_menu.cljs @@ -7,9 +7,11 @@ (ns app.main.ui.components.mcp-menu (:require-macros [app.main.style :as stl]) (:require + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] [app.main.data.workspace.mcp :as mcp] + [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.ds.buttons.button :refer [button*]] @@ -36,13 +38,13 @@ open-timer* (mf/use-ref nil) close-timer* (mf/use-ref nil) - on-open-menu + on-toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) (cancel-timer! open-timer*) (cancel-timer! close-timer*) - (reset! menu-open* true))) + (swap! menu-open* not))) on-close-menu (mf/use-fn @@ -80,7 +82,7 @@ (mf/deps dashboard) #(st/emit! (mcp/connect-mcp) (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin (if dashboard "dashboard:sidebar" "workspace:toolbar")}))) + ::ev/origin (if dashboard "dashboard:header" "workspace:toolbar")}))) on-disconnect (mf/use-fn @@ -102,8 +104,8 @@ [:div {:class (stl/css-case :mcp-tool true :dashboard dashboard) :on-pointer-enter on-display-menu :on-pointer-leave on-hide-menu} - [:> button* {:variant "ghost" - :on-click on-open-menu + [:> button* {:variant (if dashboard "secondary" "ghost") + :on-click on-toggle-menu :aria-haspopup true :aria-expanded menu-open? :aria-pressed menu-open? @@ -115,9 +117,7 @@ :connected is-mcp-connected)}] [:span {:class (stl/css-case :toolbar-mcp-button-label true :connected is-mcp-connected)} - (if (and dashboard is-mcp-connected) - (tr "workspace.toolbar.mcp-connected") - (tr "workspace.toolbar.mcp"))]]] + (tr "workspace.toolbar.mcp")]]] [:div {:class (stl/css :toolbar-mcp-menu)} [:> dropdown-menu* {:show menu-open? @@ -139,3 +139,14 @@ (if is-connection-requested (tr "workspace.header.menu.mcp.plugin.status.disconnect") (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) + +(mf/defc dashboard-mcp-menu* + [] + (let [mcp-state (mf/deref refs/mcp)] + (when (and (contains? cf/flags :mcp) + (:enabled mcp-state) + (:token-valid mcp-state)) + [:> mcp-menu* {:dashboard true + :is-mcp-connected (= "connected" (:connection-status mcp-state)) + :is-connection-requested (:connection-requested mcp-state) + :session-id (:session-id mcp-state)}]))) diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss index f4c69ba4c8..fd466d4206 100644 --- a/frontend/src/app/main/ui/components/mcp_menu.scss +++ b/frontend/src/app/main/ui/components/mcp_menu.scss @@ -13,10 +13,10 @@ .mcp-tool { position: relative; -} -.dashboard { - margin-inline: var(--sp-l); + &.dashboard { + --mcp-disconnected-dot-color: var(--color-foreground-secondary); + } } .toolbar-mcp-button { @@ -38,7 +38,7 @@ inline-size: px2rem(6); block-size: px2rem(6); border-radius: 50%; - background-color: var(--color-background-disabled); + background-color: var(--mcp-disconnected-dot-color, var(--color-background-disabled)); &.connected { background-color: var(--color-accent-primary); @@ -72,6 +72,7 @@ .toolbar-mcp-menu { position: absolute; + z-index: var(--z-index-dropdown); left: 0; top: $sz-36; } @@ -85,7 +86,6 @@ display: flex; flex-direction: column; gap: var(--sp-xs); - z-index: var(--z-index-dropdown); margin: 0; padding: var(--sp-xs); border: $b-2 solid var(--panel-border-color); diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 2f41c028e6..6bef519d98 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -16,6 +16,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.grid :refer [grid*]] [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] @@ -64,6 +65,7 @@ [:div#dashboard-deleted-title {:class (stl/css :dashboard-title)} [:h1 (tr "dashboard.projects-title")]] [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}]]]) (mf/defc project-context-menu* diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index b3318fc6f8..2d6a98f519 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -14,6 +14,7 @@ [app.main.data.project :as dpj] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.grid :refer [grid*]] [app.main.ui.dashboard.import :as udi] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -102,6 +103,7 @@ (:name project)]])) [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}] diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index fef26f5b9b..2efbbefd21 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -17,6 +17,7 @@ [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.deleted :as deleted] [app.main.ui.dashboard.grid :refer [line-grid*]] [app.main.ui.dashboard.import :as udi] @@ -50,6 +51,7 @@ (tr "dashboard.personal-projects") (tr "dashboard.projects-title"))]] [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}] (when can-edit diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 8787c290ca..2f37187244 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -23,7 +23,6 @@ [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] - [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -256,7 +255,6 @@ (get default-project :id) team-id (get team :id) - mcp-state (mf/deref refs/mcp) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -384,15 +382,6 @@ [: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)} - [:> mcp-menu* {:dashboard true - :is-mcp-connected (= "connected" (:connection-status mcp-state)) - :is-connection-requested (:connection-requested mcp-state) - :session-id (:session-id mcp-state)}]]) - [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} From cf1fbb4d7cf0f2ea3620516da5ba3f9378bcbc7f Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:23:47 +0200 Subject: [PATCH 06/10] :bug: Close plugins whose load is cancelled before they run Unloading or replacing a plugin while its code was still being fetched could leave a late instance running, or block reopening a plugin that closed during startup. Each load now carries an AbortController. Unloading, a newer load of the same plugin or logout aborts it, and the plugin manager closes itself on abort, also while it fetches its code. createPlugin returns nothing for a plugin closed before its sandbox exists, and failed loads clear their pending state so the plugin can be loaded again. --- plugins/CHANGELOG.md | 1 + .../src/lib/create-plugin.spec.ts | 1 + .../plugins-runtime/src/lib/create-plugin.ts | 12 +- .../src/lib/load-plugin-context.spec.ts | 3 +- .../src/lib/load-plugin-real-path.spec.ts | 189 +++++++++++++++++- .../src/lib/load-plugin.spec.ts | 13 +- .../plugins-runtime/src/lib/load-plugin.ts | 27 ++- .../plugins-runtime/src/lib/plugin-manager.ts | 21 +- 8 files changed, 241 insertions(+), 26 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 94cd3f268c..433f43e8c2 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🩹 Fixes +- **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts index 29d0b6d691..7960f77ec2 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts @@ -83,6 +83,7 @@ describe('createPlugin', () => { manifest, expect.any(Function), expect.any(Function), + undefined, ); expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined); expect(mockSandbox.evaluate).toHaveBeenCalled(); diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index fc4ca233f3..c2b4a2f548 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -8,8 +8,13 @@ export async function createPlugin( manifest: Manifest, onCloseCallback: () => void, apiExtensions?: object, + signal?: AbortSignal, ) { + // Closing can happen while the manager is still fetching code. + let sandbox: ReturnType | undefined = undefined; + const evaluateSandbox = async () => { + if (plugin.destroyed || !sandbox) return; try { sandbox.evaluate(); } catch (error) { @@ -23,15 +28,18 @@ export async function createPlugin( context, manifest, function onClose() { - sandbox.cleanGlobalThis(); + sandbox?.cleanGlobalThis(); onCloseCallback(); }, function onReloadModal() { evaluateSandbox(); }, + signal, ); - const sandbox = createSandbox(plugin, apiExtensions); + if (plugin.destroyed) return; + + sandbox = createSandbox(plugin, apiExtensions); await evaluateSandbox(); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts index 52a0267db0..9305878b3f 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts @@ -30,7 +30,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => { close: vi.fn(), sendMessage: vi.fn(), }, - } as unknown as Awaited>); + } as unknown as NonNullable>>); }); it('does not freeze host-owned functions reachable through the context', async () => { @@ -60,6 +60,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); // Host-owned functions must remain extensible: page navigation and 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 2200f434b1..f921455fbe 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 @@ -52,15 +52,23 @@ function makeManifest( function makeHostFixture() { const listenerTypes: string[] = []; - const listeners = new Map(); + const listeners = new Map< + symbol, + { type: string; callback: (...args: unknown[]) => unknown } + >(); + const shapes: object[] = []; // Inline code (empty host + non-URL code) resolves without network, so no // fetch mock is needed. UI/modal APIs are never touched by the probe code. - const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' })); + const createRectangle = vi.fn(() => { + const shape = { type: 'rectangle-marker' }; + shapes.push(shape); + return shape; + }); const selection: object[] = [{ id: 'shape-1' }]; const context = { - addListener: (type: string, _callback: (...args: unknown[]) => unknown) => { + addListener: (type: string, callback: (...args: unknown[]) => unknown) => { const id = Symbol(type); - listeners.set(id, type); + listeners.set(id, { type, callback }); listenerTypes.push(type); return id; }, @@ -68,13 +76,21 @@ function makeHostFixture() { listeners.delete(id); }, theme: 'dark', + management: { workspace: { status: 'ready' } }, createRectangle, selection, // Host-only member: present on the raw context but NOT part of the // public penpot API. Plugin code must never see it (see B-2 below). __internalSecret: 'host-internal', } as unknown as Context; - return { context, listeners, listenerTypes, createRectangle, selection }; + return { + context, + listeners, + listenerTypes, + createRectangle, + selection, + shapes, + }; } function lastCompartmentGlobalThis(): Record { @@ -182,7 +198,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { const fixture = makeHostFixture(); setContextBuilder(() => fixture.context); const manifest = { - ...makeManifest('plugin.js', []), + ...makeManifest('plugin.js', ['content:read', 'content:write']), host: 'https://plugins.test/', scope: 'global' as const, }; @@ -207,9 +223,10 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { if (order === 'after') await replacement(); finishFetch({ ok: true, - text: async () => 'globalThis.marker = "cancelled";', + text: async () => 'penpot.createRectangle();', }); await cancelled; + expect(fixture.shapes).toEqual([]); if (order === 'before') { expect(getPlugins()).toHaveLength(0); expect(fixture.listeners.size).toBe(0); @@ -225,6 +242,74 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { }, ); + it('cancels a workspace load when another load replaces the same plugin', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', ['content:read', 'content:write']), + host: 'https://plugins.test/', + }; + let finishFetch!: (response: object) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + try { + const cancelled = loadPlugin(manifest); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' }); + await cancelled; + expect(fixture.shapes).toEqual([]); + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + } finally { + for (const plugin of [...getPlugins()]) plugin.plugin.close(); + vi.unstubAllGlobals(); + } + }); + + it('ignores a fetch failure after a load has been cancelled', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + host: 'https://plugins.test/', + scope: 'global' as const, + }; + let failFetch!: (error: Error) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((_resolve, reject) => { + failFetch = reject; + }), + ); + try { + const cancelled = loadPlugin(manifest); + ɵunloadPlugin(manifest.pluginId); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + failFetch(new Error('Cancelled fetch failed')); + await cancelled; + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + it('allows retrying a global plugin after a failed load', async () => { const fixture = makeHostFixture(); setContextBuilder(() => fixture.context); @@ -315,4 +400,94 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(globals['penpotMgmt']).toBeUndefined(); expect(listeners.size).toBe(0); }); + + it('cancels a global load at logout before it can execute in another session', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', ['content:read', 'content:write']), + pluginId: 'logout-plugin', + host: 'https://plugins.test/', + scope: 'global' as const, + }; + let finishFetch!: (response: object) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + try { + const cancelled = loadPlugin(manifest); + for (const listener of [...fixture.listeners.values()]) { + if (listener.type === 'logout') listener.callback(); + } + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "new-session";', + }); + finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' }); + await cancelled; + expect(fixture.shapes).toEqual([]); + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('new-session'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + + it('cleans up a failed fetch and allows loading the plugin again', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + pluginId: 'failed-fetch-plugin', + host: 'https://plugins.test/', + scope: 'global' as const, + }; + vi.stubGlobal('fetch', () => Promise.reject(new Error('Fetch failed'))); + try { + await expect(loadPlugin(manifest)).rejects.toThrow('Fetch failed'); + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "retry";', + }); + expect(lastCompartmentGlobalThis()['marker']).toBe('retry'); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + + it('does not register a global plugin that closes during startup and permits reopening', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('penpot.closePlugin();', [ + 'content:read', + 'content:write', + ]), + pluginId: 'self-closing-plugin', + scope: 'global' as const, + }; + try { + await loadPlugin(manifest); + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await loadPlugin({ ...manifest, code: 'penpot.createRectangle();' }); + expect(fixture.shapes).toEqual([{ type: 'rectangle-marker' }]); + expect(getPlugins()).toHaveLength(1); + } finally { + ɵunloadPlugin(manifest.pluginId); + } + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 779619279c..eeffb3d236 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -33,7 +33,7 @@ vi.mock('./ses.js', () => ({ describe('plugin-loader', () => { let mockContext: Context; let manifest: Manifest; - let mockPluginApi: Awaited>; + let mockPluginApi: NonNullable>>; let mockClose: ReturnType; beforeEach(() => { @@ -61,7 +61,7 @@ describe('plugin-loader', () => { close: mockClose, sendMessage: vi.fn(), }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; mockContext = { addListener: vi.fn(), @@ -84,6 +84,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); expect(mockPluginApi.plugin.close).not.toHaveBeenCalled(); expect(getPlugins()).toHaveLength(1); @@ -129,7 +130,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow, manifest: { ...manifest, host: 'http://localhost:4202' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); @@ -173,7 +174,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow1, manifest: { ...manifest, host: 'http://localhost:4202' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; const mockPluginApi2 = { plugin: { @@ -182,7 +183,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow2, manifest: { ...manifest, host: 'http://localhost:4203' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); await loadPlugin(manifest); @@ -250,6 +251,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); }); @@ -265,6 +267,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index bad0d4fd63..8da32c4d64 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -4,8 +4,10 @@ import { loadManifest } from './parse-manifest.js'; import { Manifest } from './models/manifest.model.js'; import { createPlugin } from './create-plugin.js'; -let plugins: Awaited>[] = []; -const pendingPlugins = new Map(); +type Plugin = NonNullable>>; + +let plugins: Plugin[] = []; +const pendingPlugins = new Map(); export type ContextBuilder = (id: string) => Context; @@ -50,7 +52,7 @@ export const loadPlugin = async function ( closeCallback?: () => void, apiExtensions?: object, ) { - const loadId = Symbol(); + const load = new AbortController(); try { const context = contextBuilder && contextBuilder(manifest.pluginId); @@ -86,13 +88,17 @@ 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. - pendingPlugins.set(manifest.pluginId, loadId); - let plugin: Awaited> | undefined = - undefined; + pendingPlugins.get(manifest.pluginId)?.abort(); + pendingPlugins.set(manifest.pluginId, load); + let plugin: Plugin | undefined = undefined; plugin = await createPlugin( context, manifest, () => { + load.abort(); + if (pendingPlugins.get(manifest.pluginId) === load) { + pendingPlugins.delete(manifest.pluginId); + } plugins = plugins.filter((api) => api !== plugin); if (closeCallback) { @@ -100,9 +106,10 @@ export const loadPlugin = async function ( } }, apiExtensions, + load.signal, ); - if (pendingPlugins.get(manifest.pluginId) !== loadId) { - plugin.plugin.close(); + if (!plugin || load.signal.aborted) { + plugin?.plugin.close(); return; } plugins.push(plugin); @@ -110,7 +117,7 @@ export const loadPlugin = async function ( if (manifest.scope !== 'global') closeAllPlugins(); throw error; } finally { - if (pendingPlugins.get(manifest.pluginId) === loadId) { + if (pendingPlugins.get(manifest.pluginId) === load) { pendingPlugins.delete(manifest.pluginId); } } @@ -130,7 +137,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) { }; export const ɵunloadPlugin = function (id: Manifest['pluginId']) { - pendingPlugins.delete(id); + pendingPlugins.get(id)?.abort(); const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id); if (plugin) { diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index f430321210..3cb6a9c261 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -14,8 +14,9 @@ export async function createPluginManager( manifest: Manifest, onCloseCallback: () => void, onReloadModal: (code: string) => void, + signal?: AbortSignal, ) { - let code = await loadManifestCode(manifest); + let code = ''; let loaded = false; let destroyed = false; @@ -61,6 +62,7 @@ export async function createPluginManager( const closePlugin = () => { if (destroyed) return; destroyed = true; + signal?.removeEventListener('abort', closePlugin); removeAllEventListeners(); destroyListener(listenerId); destroyListener(logoutId); @@ -148,7 +150,24 @@ export async function createPluginManager( context.removeListener(listenerId); }; + signal?.addEventListener('abort', closePlugin, { once: true }); + try { + if (signal?.aborted) { + closePlugin(); + } else { + code = await loadManifestCode(manifest); + } + } catch (error) { + if (!destroyed) { + closePlugin(); + throw error; + } + } + return { + get destroyed() { + return destroyed; + }, close: closePlugin, destroyListener, openModal, From a381761aa4e2fd82f8aa75a0a9b475126e6125e2 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:05 +0200 Subject: [PATCH 07/10] :sparkles: Add project and file creation to penpotMgmt Add penpotMgmt.createProject({name, teamId?}) and penpotMgmt.createFile({projectId, name}). Both require content:write, trim the name and require 1 to 250 characters, and return the same metadata as listProjects and listFiles without changing the current view. When the new project or file belongs to the current team, the dashboard state is updated so it shows up right away. listProjects, listFiles and the creation methods share the summary builders, so fileCount is now 0 instead of missing for new projects. Update the plugin types, the MCP API docs and the MCP instructions. --- frontend/src/app/plugins/management.cljs | 104 ++++++++++++--- .../plugins/management_test.cljs | 126 ++++++++++++++++++ mcp/packages/server/data/api_types.yml | 59 +++++++- .../server/data/initial_instructions.md | 6 +- plugins/CHANGELOG.md | 1 + plugins/libs/plugin-types/index.d.ts | 46 ++++++- .../libs/plugins-runtime/src/lib/api/index.ts | 8 ++ .../src/lib/api/plugin-api.spec.ts | 88 ++++++++++++ 8 files changed, 412 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 9dadac543b..0337da56ba 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -6,13 +6,34 @@ (ns app.plugins.management (:require + [app.common.features :as cfeat] [app.common.uuid :as uuid] + [app.main.data.dashboard :as dd] [app.main.data.helpers :as dsh] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] [app.util.object :as obj] - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [clojure.set :as set] + [cuerdas.core :as str] + [potok.v2.core :as ptk])) + +(defn- project-summary + [project] + #js {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (or (:count project) 0)}) + +(defn- file-summary + [file] + #js {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) (defn workspace-context [state] @@ -83,14 +104,7 @@ (fn [resolve reject] (->> (rp/cmd! :get-projects {:team-id team-id}) (rx/map (fn [projects] - (clj->js - (mapv (fn [project] - {:id (str (:id project)) - :teamId (str (:team-id project)) - :name (:name project) - :isDefault (boolean (:is-default project)) - :fileCount (:count project)}) - (remove :deleted-at projects))))) + (into-array (map project-summary (remove :deleted-at projects))))) (rx/subs! resolve reject))))))) (defn list-files @@ -102,16 +116,68 @@ (fn [resolve reject] (->> (rp/cmd! :get-project-files {:project-id project-id}) (rx/map (fn [files] - (clj->js - (mapv (fn [file] - {:id (str (:id file)) - :teamId (str (:team-id file)) - :projectId (str (:project-id file)) - :name (:name file) - :modifiedAt (.toISOString ^js (:modified-at file))}) - files)))) + (into-array (map file-summary files)))) (rx/subs! resolve reject))))))) +(defn- creation-name + [options] + (let [name (obj/get options "name")] + (when (string? name) + (let [name (str/trim name)] + (when (<= 1 (count name) 250) + name))))) + +(defn create-project + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state)) + name (creation-name options)] + (cond + (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :create-project {:team-id team-id :name name}) + (rx/tap (fn [project] + (st/emit! + (fn [state] + (if (= (:team-id project) (:current-team-id state)) + (assoc-in state [:projects (:id project)] (assoc project :count 0)) + state))))) + (rx/map project-summary) + (rx/subs! resolve reject))))))) + +(defn create-file + [options] + (let [project-id (uuid/parse* (obj/get options "projectId")) + name (creation-name options)] + (cond + (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] + (->> (rp/cmd! :create-file {:project-id project-id :name name :features features}) + (rx/tap (fn [file] + (st/emit! + (fn [state] + (if (= (:team-id file) (:current-team-id state)) + (ptk/update (dd/file-created file) state) + state))))) + (rx/map file-summary) + (rx/subs! resolve reject)))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} @@ -119,4 +185,6 @@ {:get (fn [] (clj->js (workspace-context @st/state)))} :openFile open-file :listProjects list-projects - :listFiles list-files)) + :listFiles list-files + :createProject create-project + :createFile create-file)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 9db0ae80c0..eb74e0b09d 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -152,3 +152,129 @@ (mock/with-mocks* {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) + +(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[initial options target-team-id] + [[dashboard #js {:name " Buttons "} team-id] + [ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]] + (let [state (atom initial) + requests (atom []) + project {:id project-id :team-id target-team-id :name "Buttons" + :is-default false :is-pinned false}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of project) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createProject context options))] + (t/is (= {"id" (str project-id) "teamId" (str target-team-id) + "name" "Buttons" "isDefault" false "fileCount" 0} + (js->clj result))) + (t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests)) + (t/is (= (if (= team-id target-team-id) + (assoc-in initial [:projects project-id] (assoc project :count 0)) + initial) + @state))))))))) + +(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation + (let [project-id (uuid/next) + created-id (uuid/next) + modified-at (js/Date. "2026-01-02T03:04:05Z")] + (doseq [initial [dashboard ready]] + (let [initial (assoc initial + :features #{"components/v2" "styles/v2" "plugins/runtime"} + :projects {project-id {:id project-id :team-id team-id :count 2}}) + state (atom initial) + requests (atom []) + file {:id created-id :team-id team-id :project-id project-id + :name "Login" :modified-at modified-at :data {:pages []}}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of file) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createFile context #js {:projectId (str project-id) :name " Login " + :id "ignored" :isShared true}))] + (t/is (= {"id" (str created-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Login" + "modifiedAt" "2026-01-02T03:04:05.000Z"} + (js->clj result))) + (t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]] + @requests)) + (t/is (= (-> initial + (assoc-in [:files created-id] (dissoc file :data)) + (assoc-in [:recent-files created-id] (dissoc file :data)) + (update-in [:projects project-id :count] inc)) + @state))))))))) + +(t/deftest ^:async creation-rejects-invalid-input-before-querying + (let [project-id (uuid/next) + requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (mock/stub (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of nil) (rx/observe-on :async))))} + (let [context (management/create-context)] + (doseq [options [nil #js {} #js {:name nil} #js {:name 1} + #js {:name ""} #js {:name " \n "} + #js {:name (apply str (repeat 251 "a"))}]] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createProject context options) (fn [] nil) #(.-message %))))) + (let [options (js/Object.assign #js {:projectId (str project-id)} options)] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createFile context options) (fn [] nil) #(.-message %))))))) + (doseq [[create options message] + [[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"] + [#(.createFile context %) #js {:name "Login"} "Expected a project UUID"] + [#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]] + (t/is (= message (await (.then (create options) (fn [] nil) #(.-message %))))))) + (await (a/settle)) + (t/is (empty? @requests)))))) + +(t/deftest ^:async creation-preserves-backend-errors-and-state + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))} + (let [context (management/create-context) + options #js {:name "Design" :projectId (str (uuid/next))} + result (.call (aget context method) context options)] + (t/is (identical? error (await (.then result (fn [] nil) identity)))) + (t/is (= dashboard @state)))))))) + +(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + response (rx/subject) + other (assoc dashboard :current-team-id (uuid/next)) + item-id (uuid/next) + item {:id item-id :team-id team-id :project-id (uuid/next) + :name "Design" :modified-at (js/Date.)}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))} + (let [context (management/create-context) + result (.call (aget context method) context + #js {:name "Design" :projectId (str (:project-id item))})] + (reset! state other) + (rx/push! response item) + (t/is (= (str item-id) (.-id (await result)))) + (t/is (= other @state)))))))) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index c926147938..1fbccd549f 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5128,9 +5128,9 @@ PenpotMgmt: Application management API exposed as `penpotMgmt` to global plugins. - * Requires `content:read` permission. + * Discovery and navigation require `content:read`; creation requires `content:write`. * Available in the dashboard and file workspaces. - * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * Reacquire file, page and shape objects after changing files. ``` @@ -5138,6 +5138,8 @@ PenpotMgmt: workspace: WorkspaceContext; listProjects(options?: { teamId?: string }): Promise; listFiles(options: { projectId: string }): Promise; + createProject(options: { name: string; teamId?: string }): Promise; + createFile(options: { name: string; projectId: string }): Promise; openFile(fileId: string, options?: { teamId?: string }): Promise; on( type: "workspacechange", @@ -5199,6 +5201,51 @@ PenpotMgmt: ``` const files = await penpotMgmt.listFiles({ projectId: project.id }); ``` + createProject: |- + ``` + createProject(options: { name: string; teamId?: string }): Promise + ``` + + Creates a project and returns its metadata snapshot. Requires `content:write`. + + * `teamId` defaults to the current team. + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation or open a file. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; teamId?: string } + + Returns Promise + + Example + ``` + const project = await penpotMgmt.createProject({ name: 'Design system' }); + ``` + createFile: |- + ``` + createFile(options: { name: string; projectId: string }): Promise + ``` + + Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + + * Requires an explicit `projectId`. + * Trims `name` and requires 1 to 250 characters. + * Does not open the file or change navigation. Use `openFile` to edit it. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; projectId: string } + + Returns Promise + + Example + ``` + const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` openFile: |- ``` openFile(fileId: string, options?: { teamId?: string }): Promise @@ -5260,7 +5307,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", >; version: string; root: Shape | null; @@ -5340,7 +5389,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", > ``` version: |- diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index ea5961c605..6fd27c2e81 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -435,7 +435,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u # The `penpotMgmt` Object This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. -It allows you to discover and navigate to different design files the user has access to. +It allows you to discover, create and navigate to design projects and files the user has access to. +Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`). +Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`. +Both creation methods require `content:write` and return metadata without changing the current view. +To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. -- diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 433f43e8c2..ab324f891c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,7 @@ ### 🚀 Features +- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **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. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index d1d145fe96..f375f42cd3 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -809,9 +809,9 @@ export interface FileSummary { /** * Application management API exposed as `penpotMgmt` to global plugins. * - * - Requires `content:read` permission. + * - Discovery and navigation require `content:read`; creation requires `content:write`. * - Available in the dashboard and file workspaces. - * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * - Reacquire file, page and shape objects after changing files. */ export interface PenpotMgmt { @@ -843,6 +843,41 @@ export interface PenpotMgmt { * ``` */ listFiles(options: { projectId: string }): Promise; + /** + * Creates a project and returns its metadata snapshot. Requires `content:write`. + * + * - `teamId` defaults to the current team. + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation or open a file. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const project = await penpotMgmt.createProject({ name: 'Design system' }); + * ``` + */ + createProject(options: { + name: string; + teamId?: string; + }): Promise; + /** + * Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + * + * - Requires an explicit `projectId`. + * - Trims `name` and requires 1 to 250 characters. + * - Does not open the file or change navigation. Use `openFile` to edit it. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ + createFile(options: { + name: string; + projectId: string; + }): Promise; /** * Opens a file in this tab and resolves once its workspace and page are ready. * @@ -869,7 +904,12 @@ export interface PenpotMgmt { export interface Context { readonly management?: Pick< PenpotMgmt, - 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + | 'workspace' + | 'openFile' + | 'listProjects' + | 'listFiles' + | 'createProject' + | 'createFile' >; /** * Returns the current penpot version. diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index ba36b1e12a..44f5645cc5 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -427,6 +427,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.listFiles(options); }, + createProject(options) { + checkPermission('content:write', false); + return plugin.context.management!.createProject(options); + }, + createFile(options) { + checkPermission('content:write', false); + return plugin.context.management!.createFile(options); + }, openFile(fileId, options) { checkPermission('content:read', false); return plugin.context.management!.openFile(fileId, options); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 3513b97acd..e53eddacc3 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -108,6 +108,94 @@ describe('Plugin api', () => { }); }); + describe('management creation', () => { + it.each(['none', 'loading', 'ready'])( + 'creates projects and files with workspace status %s', + async (status) => { + const project = { id: 'project-id', name: 'Buttons' }; + const file = { id: 'file-id', name: 'Login' }; + const management = { + workspace: { status }, + createProject: vi.fn().mockResolvedValue(project), + createFile: vi.fn().mockResolvedValue(file), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: ['content:write'], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + await expect( + penpotMgmt.createProject({ name: 'Buttons' }), + ).resolves.toEqual(project); + await expect( + penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }), + ).resolves.toEqual(project); + expect(management.createProject).toHaveBeenLastCalledWith({ + name: 'Buttons', + teamId: 'team-id', + }); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).resolves.toEqual(file); + expect(management.createFile).toHaveBeenCalledWith({ + name: 'Login', + projectId: 'project-id', + }); + }, + ); + + it.each([{ permissions: [] }, { permissions: ['content:read'] }])( + 'requires content:write before creating with permissions $permissions', + ({ permissions }) => { + const management = { + createProject: vi.fn(), + createFile: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, permissions, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow( + 'Permission content:write is not granted', + ); + expect(() => + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).toThrow('Permission content:write is not granted'); + expect(management.createProject).not.toHaveBeenCalled(); + expect(management.createFile).not.toHaveBeenCalled(); + }, + ); + + it('preserves backend creation errors', async () => { + const error = new Error('Access denied'); + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { + ...pluginManager.context, + management: { + createProject: vi.fn().mockRejectedValue(error), + createFile: vi.fn().mockRejectedValue(error), + }, + }, + } as any); + + await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe( + error, + ); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).rejects.toBe(error); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({ From dfff188409799865d6b05151d43e8c55af813c31 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:12 +0200 Subject: [PATCH 08/10] :sparkles: Ask users to allow global plugins on install A plugin with "scope": "global" keeps running across the dashboard and files and can find and open the user's projects and files. Any manifest could ask for this without the user being told. Add the allow:global permission. The plugin registry adds it to every manifest with global scope, so the install dialog lists it, and a plugin that becomes global in a later version shows the permissions update dialog again. The permission is valid in the shared schema, which the backend checks when saving, and in the runtime manifest schema. The built-in MCP plugin declares it too. Document the scope property and the new permission for plugin developers. --- .../test/backend_tests/rpc_plugins_test.clj | 11 ++++++++++ common/src/app/common/types/plugins.cljc | 5 +++-- docs/plugins/getting-started.md | 4 ++++ frontend/src/app/main/data/workspace/mcp.cljs | 3 ++- .../src/app/main/ui/workspace/plugins.cljs | 7 +++++++ frontend/src/app/plugins/register.cljs | 17 +++++++++------ .../frontend_tests/plugins/register_test.cljs | 21 +++++++++++++++++++ frontend/translations/en.po | 6 ++++++ frontend/translations/es.po | 6 ++++++ plugins/CHANGELOG.md | 2 +- .../src/lib/models/manifest.schema.ts | 1 + 11 files changed, 73 insertions(+), 10 deletions(-) diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj index 8c57931844..8d6be84dc6 100644 --- a/backend/test/backend_tests/rpc_plugins_test.clj +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -47,6 +47,17 @@ (t/is (= [plugin-id-1] (:ids plugins))) (t/is (= valid-plugin (get-in plugins [:data plugin-id-1])))))) +(t/deftest add-profile-plugin-accepts-global-plugins + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin + :scope "global" + :permissions #{"content:read" "allow:global"}) + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin})] + (t/is (nil? (:error out))) + (t/is (= plugin (:result out))))) + (t/deftest add-profile-plugin-rejects-invalid-permissions (let [profile (th/create-profile* 1) plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"}) diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index b9a1ca5330..8db72497fd 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -35,11 +35,12 @@ "clipboard:read" "clipboard:write" "user:read" "allow:downloads" - "allow:localstorage"}) + "allow:localstorage" + "allow:global"}) (def schema:permissions "Schema for plugin permissions - a set of valid permission strings." - [:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) + [:set {:gen/max 12} (into [:enum] (sort valid-permissions))]) (def max-plugins "Maximum number of plugins a profile can hold." diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md index ea993640a9..5f72ac4ada 100644 --- a/docs/plugins/getting-started.md +++ b/docs/plugins/getting-started.md @@ -160,6 +160,7 @@ Set "version": 2 in your - **Code**: your plugin's file location. It needs to be compiled to JavaScript and reachable. - **Icon**: your plugin's icon, which will be also displayed in the plugin manager modal. It'll be a tag so you can use whichever image format works better for you. **It's recommended to use a 56x56 pixel icon for the best appearance on all devices**. - **Permissions**: your plugin's permissions, which allow access to different parts of the Penpot API. +- **Scope** (optional): "workspace" (the default) or "global". A workspace plugin closes when its file closes. A global plugin keeps running while the user moves between the dashboard and files, and gets the penpotMgmt object to find, create and open projects and files. Global scope adds the allow:global permission, which the user must accept. #### Types of permissions @@ -185,6 +186,9 @@ Typical use cases: downloading the full project file for backup or sharing. - allow:localstorage: Allows the access to the local storage proxy to store information. This info is only available for the plugin installation but be aware that a user can see this information in the browser. Typical use cases: storing authentication tokens for a plugin login +- allow:global: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets "scope": "global". +Typical use cases: assistants or integrations that work across several files. + _Note: Write permissions automatically includes its corresponding read permission (e.g., content:write includes content:read) because reading is required to perform write or modification actions._ ### What are plugin.ts and plugin.js files? diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index f3e3817239..e4b03fb956 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -42,7 +42,8 @@ #{"user:read" "library:read" "library:write" "comment:read" "comment:write" - "content:write" "content:read"}}) + "content:write" "content:read" + "allow:global"}}) (defonce interval-sub (atom nil)) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index a6ff1b34f0..12bd45e766 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -331,6 +331,13 @@ [:p {:class (stl/css :permissions-list-text)} (tr "workspace.plugins.permissions.allow-localstorage")]]) + (cond + (contains? permissions "allow:global") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.allow-global")]]) + (cond (contains? permissions "clipboard:write") [:div {:class (stl/css :permissions-list-entry)} diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index fc709427d4..8b5bcc3bde 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -54,15 +54,20 @@ "Read the manifest.json defined by the plugins definition and transforms it into an object that will be stored in the register." [plugin-url ^js manifest] - (let [name (obj/get manifest "name") - desc (obj/get manifest "description") - code (obj/get manifest "code") - icon (obj/get manifest "icon") - vers (d/nilv (obj/get manifest "version") 1) + (let [name (obj/get manifest "name") + desc (obj/get manifest "description") + code (obj/get manifest "code") + icon (obj/get manifest "icon") + vers (d/nilv (obj/get manifest "version") 1) + scope (obj/get manifest "scope") permissions (into #{} (obj/get manifest "permissions" [])) permissions (cond-> permissions + ;; Global plugins must be explicitly allowed by the user + (= "global" scope) + (conj "allow:global") + (contains? permissions "content:write") (conj "content:read") @@ -102,7 +107,7 @@ {:plugin-id plugin-id :url (str plugin-url) :version vers - :scope (obj/get manifest "scope") + :scope scope :name name :description desc :host origin diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs index 55ddd1c4a6..49c387d4bc 100644 --- a/frontend/test/frontend_tests/plugins/register_test.cljs +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -320,3 +320,24 @@ (t/is (= [true false] @seen) "no calls after unsubscribing") (done')) done)))) + +;; --- parse-manifest --- + +(t/deftest parse-manifest-adds-allow-global-for-global-scope + (let [manifest (preg/parse-manifest + "http://localhost:4400/manifest.json" + #js {:name "Global plugin" + :code "plugin.js" + :scope "global" + :permissions #js ["content:read"]})] + (t/is (= "global" (:scope manifest))) + (t/is (= #{"content:read" "allow:global"} (:permissions manifest))))) + +(t/deftest parse-manifest-keeps-workspace-plugins-without-allow-global + (let [manifest (preg/parse-manifest + "http://localhost:4400/manifest.json" + #js {:name "Workspace plugin" + :code "plugin.js" + :permissions #js ["content:read"]})] + (t/is (nil? (:scope manifest))) + (t/is (= #{"content:read"} (:permissions manifest))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index d71621ab02..18000c059e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8180,6 +8180,12 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." +#: src/app/main/ui/workspace/plugins.cljs:339 +msgid "workspace.plugins.permissions.allow-global" +msgstr "" +"Keep running while you move between the dashboard and your files, and find " +"and open your projects and files." + #: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index c1464fe980..03bac196ed 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8276,6 +8276,12 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Comenzar descargas de ficheros." +#: src/app/main/ui/workspace/plugins.cljs:339 +msgid "workspace.plugins.permissions.allow-global" +msgstr "" +"Seguir funcionando mientras te mueves entre el panel y tus archivos, y " +"buscar y abrir tus proyectos y archivos." + #: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Guardar datos en el navegador." diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index ab324f891c..041d252969 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -3,7 +3,7 @@ ### 🚀 Features - **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. -- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. - **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/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index aefc563c65..357fe9f95f 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -20,6 +20,7 @@ export const manifestSchema = z.object({ 'comment:write', 'allow:downloads', 'allow:localstorage', + 'allow:global', 'clipboard:read', 'clipboard:write', ]), From a125977fce0d4786224050a19a1b62adabfde627 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:17 +0200 Subject: [PATCH 09/10] :bug: Keep the MCP plugin working without penpotMgmt Penpot only exposes penpotMgmt to plugins running with global scope. Without it, the MCP plugin failed on load when registering its workspacechange listener. When penpotMgmt is missing, the plugin now builds the workspace metadata from the open file and skips the listener. execute_code receives penpotMgmt as null and waits for layout updates as before. The tool description and instructions tell agents that only the open file is available when penpotMgmt is null. --- .../plugin/src/ConnectionMetadata.test.ts | 38 +++++++++++++++++++ mcp/packages/plugin/src/index.d.ts | 3 +- mcp/packages/plugin/src/plugin.ts | 29 ++++++++++++-- .../task-handlers/ExecuteCodeTaskHandler.ts | 5 ++- .../server/data/initial_instructions.md | 2 + .../server/src/tools/ExecuteCodeTool.ts | 1 + 6 files changed, 72 insertions(+), 6 deletions(-) diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 91aadb8188..3131086153 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -192,6 +192,44 @@ test("plugin reads the current file for each connection metadata request", () => ]); }); +test("plugin without penpotMgmt derives metadata from the open file", () => { + let onMessage!: (message: any) => void; + const messages: any[] = []; + const penpot = { + currentFile: { id: "file-1", name: "Design" } as { id: string; name: string } | null, + currentUser: { sessionId: "tab-1" }, + theme: "dark", + ui: { + open() {}, + onMessage: (handler: (message: any) => void) => { + onMessage = handler; + }, + sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))), + }, + on() {}, + }; + runInNewContext(compile("plugin.ts"), { + exports: {}, + penpot, + mcp: undefined, + require: () => ({ ExecuteCodeTaskHandler: class {} }), + }); + onMessage({ type: "connection-metadata-request", sessionId: "first" }); + penpot.currentFile = null; + onMessage({ type: "context-request" }); + assert.deepEqual(messages, [ + { + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: "first", fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + }, + { + type: "context-update", + context: { fileId: null, fileName: null, workspaceState: "none" }, + }, + ]); +}); + test("integrated connections reuse the short session ID across reconnects", async () => { const ui = pluginUi(); ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index 67a498275f..b53afdef68 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,7 +8,8 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; - const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt; + /** management API; only exposed to plugins running with global scope */ + const penpotMgmt: undefined | null | import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index bb55706a10..b3e8c691bc 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -9,6 +9,12 @@ import type { PluginConnectionInit } from "../../common/src"; */ const isIntegratedRemoteMcp = !!mcp; +/** + * the management API, which Penpot only exposes to plugins running with global scope; + * null otherwise (e.g. in Penpot versions without global plugins) + */ +const management = typeof penpotMgmt === "undefined" ? null : penpotMgmt; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -66,7 +72,7 @@ penpot.ui.onMessage< }); } } else if (typeof message === "object" && message.type === "connection-metadata-request") { - const workspace = penpotMgmt.workspace; + const workspace = currentWorkspace(); if (message.sessionId) { const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = { type: "initialize", @@ -92,9 +98,26 @@ penpot.ui.onMessage< } }); +/** + * Provides the workspace metadata of this tab. + * Without the management API, the metadata is derived from the open file. + */ +function currentWorkspace(): NonNullable["workspace"] { + if (management) { + return management.workspace; + } + const file = penpot.currentFile; + return { + status: file ? "ready" : "none", + fileId: file?.id ?? null, + fileName: file?.name ?? null, + teamId: null, + }; +} + /** Sends the current workspace metadata without replacing the connection. */ function sendWorkspaceContext(): void { - const workspace = penpotMgmt.workspace; + const workspace = currentWorkspace(); penpot.ui.sendMessage({ type: "context-update", context: { @@ -105,7 +128,7 @@ function sendWorkspaceContext(): void { }); } -penpotMgmt.on("workspacechange", sendWorkspaceContext); +management?.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 2132446f80..47004e5cfe 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -178,7 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // initialize context, making penpot, penpotUtils, storage and the custom console available this.context = { penpot: penpot, - penpotMgmt: penpotMgmt, + penpotMgmt: typeof penpotMgmt === "undefined" ? null : penpotMgmt, storage: {}, console: new ExecuteCodeTaskConsole(), penpotUtils: PenpotUtils, @@ -217,8 +217,9 @@ export class ExecuteCodeTaskHandler extends TaskHandler { try { // wait for layout updates prior to executing the supplied code (if method is available) try { + const management = this.context.penpotMgmt; // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types - if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) { + if ((!management || management.workspace.status === "ready") && penpot.waitForLayoutUpdate) { // @ts-ignore await penpot.waitForLayoutUpdate(); } diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 6fd27c2e81..437c58c7ab 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -441,6 +441,8 @@ Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: " Both creation methods require `content:write` and return metadata without changing the current view. To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. +If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used; +ask the user to open another file if needed. -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 7a8e710e33..98932a154a 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -50,6 +50,7 @@ export class ExecuteCodeTool extends Tool { "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 to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" + + "If `penpotMgmt` is null, file management is unavailable and only the open file can be used.\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; " + From 9517f2ac9426f8058808f7df690acb23ad24d8f1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 15:20:32 +0200 Subject: [PATCH 10/10] :recycle: Type the MCP plugin with the repo plugin types Link the MCP plugin to plugins/libs/plugin-types instead of the published 1.5.0 package, which lacks PenpotMgmt. The repo types declare the penpotMgmt global, so the inline import in index.d.ts goes away and the plugin checks against the API it runs with. Type Context.management as PenpotMgmt without on/off, so new management methods need no extra listing. Mark the background plugin mock in load-plugin.spec.ts as NonNullable: createPlugin can return undefined, and the build type-check failed on it. --- mcp/packages/plugin/package.json | 2 +- mcp/packages/plugin/src/index.d.ts | 2 -- mcp/packages/plugin/src/plugin.ts | 3 ++- mcp/packages/server/data/api_types.yml | 23 ++++--------------- mcp/pnpm-lock.yaml | 9 ++------ plugins/libs/plugin-types/index.d.ts | 14 ++++------- .../src/lib/load-plugin.spec.ts | 2 +- 7 files changed, 16 insertions(+), 39 deletions(-) diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 1a44059515..d4a3723755 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@penpot/plugin-styles": "1.5.0", - "@penpot/plugin-types": "1.5.0" + "@penpot/plugin-types": "link:../../../plugins/libs/plugin-types" }, "devDependencies": { "cross-env": "^10.1.0", diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index b53afdef68..1fa11567bb 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,8 +8,6 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; - /** management API; only exposed to plugins running with global scope */ - const penpotMgmt: undefined | null | import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index b3e8c691bc..025354df74 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -2,6 +2,7 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; import { formatTaskError } from "./ErrorUtils"; import type { PluginConnectionInit } from "../../common/src"; +import type { WorkspaceContext } from "@penpot/plugin-types"; /** * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server @@ -102,7 +103,7 @@ penpot.ui.onMessage< * Provides the workspace metadata of this tab. * Without the management API, the metadata is derived from the open file. */ -function currentWorkspace(): NonNullable["workspace"] { +function currentWorkspace(): WorkspaceContext { if (management) { return management.workspace; } diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 1fbccd549f..7fc050cec7 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5302,15 +5302,7 @@ Context: ``` interface Context { - management?: Pick< - PenpotMgmt, - "workspace" - | "openFile" - | "listProjects" - | "listFiles" - | "createProject" - | "createFile", - >; + management?: Omit; version: string; root: Shape | null; currentFile: File | null; @@ -5384,16 +5376,11 @@ Context: Properties: management: |- ``` - readonly management?: Pick< - PenpotMgmt, - "workspace" - | "openFile" - | "listProjects" - | "listFiles" - | "createProject" - | "createFile", - > + readonly management?: Omit ``` + + Backs `penpotMgmt`. Its events go through the plugin listener registry, + so `on` and `off` are not part of it. version: |- ``` readonly version: string diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 1ebfccbe6d..854a012147 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -188,8 +188,8 @@ importers: specifier: 1.5.0 version: 1.5.0 '@penpot/plugin-types': - specifier: 1.5.0 - version: 1.5.0 + specifier: link:../../../plugins/libs/plugin-types + version: link:../../../plugins/libs/plugin-types devDependencies: cross-env: specifier: ^10.1.0 @@ -663,9 +663,6 @@ packages: '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} - '@penpot/plugin-types@1.5.0': - resolution: {integrity: sha512-hFk4Rx66rLaSUWoN6bhxXkDI2ymwKg9xokdslvZvXM5MSmEmfhsJKUMwtuzgYWljbbfaQhK+DjK8g8yVbbavtA==} - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1880,8 +1877,6 @@ snapshots: '@penpot/plugin-styles@1.5.0': {} - '@penpot/plugin-types@1.5.0': {} - '@pinojs/redact@0.4.0': {} '@rolldown/binding-android-arm-eabi@1.2.11': diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index f375f42cd3..031f7cc3ba 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -902,15 +902,11 @@ export interface PenpotMgmt { * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { - readonly management?: Pick< - PenpotMgmt, - | 'workspace' - | 'openFile' - | 'listProjects' - | 'listFiles' - | 'createProject' - | 'createFile' - >; + /** + * Backs `penpotMgmt`. Its events go through the plugin listener registry, + * so `on` and `off` are not part of it. + */ + readonly management?: Omit; /** * Returns the current penpot version. */ diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index eeffb3d236..853c1d12c7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -216,7 +216,7 @@ describe('plugin-loader', () => { pluginId: 'background-plugin', allowBackground: true, }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(backgroundPluginApi); await loadPlugin(manifest);