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; " +