✨ 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 <dominik.jain@oraios-ai.de>
This commit is contained in:
Dominik Jain 2026-10-02 14:32:56 +00:00 committed by alonso.torres
parent 54cce66c13
commit 2bb6cd557e
10 changed files with 532 additions and 23 deletions

View File

@ -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))

View File

@ -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)))))))))

View File

@ -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)

View File

@ -92,7 +92,7 @@ Penpot:
Hierarchy
* Omit<Context, "addListener" | "removeListener">
* Omit<Context, "addListener" | "removeListener" | "management">
+ 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<ProjectSummary[]>;
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
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<ProjectSummary[]>
```
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<ProjectSummary[]>
Example
```
const projects = await penpotMgmt.listProjects();
```
listFiles: |-
```
listFiles(options: { projectId: string }): Promise<FileSummary[]>
```
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<FileSummary[]>
Example
```
const files = await penpotMgmt.listFiles({ projectId: project.id });
```
openFile: |-
```
openFile(fileId: string, options?: { teamId?: string }): Promise<void>
```
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<void>
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

View File

@ -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.

View File

@ -49,10 +49,7 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
"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; " +

View File

@ -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

View File

@ -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<ProjectSummary[]>;
/**
* 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<FileSummary[]>;
/**
* 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<void>;
/** 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<PenpotMgmt, 'workspace' | 'openFile'>;
readonly management?: Pick<
PenpotMgmt,
'workspace' | 'openFile' | 'listProjects' | 'listFiles'
>;
/**
* Returns the current penpot version.
*/

View File

@ -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);

View File

@ -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({