From b7d1171654ef0a5d73f4a893acc444a97694100e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 12 Sep 2025 15:40:17 +0200 Subject: [PATCH] Establish return channel when executing plugin tasks and package 'common' for representations used in both subprojects --- .serena/memories/project_overview.md | 67 ++++++++++--- README.md | 59 ++++++++++-- common/package-lock.json | 29 ++++++ common/package.json | 17 ++++ common/src/index.ts | 1 + common/src/types.ts | 70 ++++++++++++++ common/tsconfig.json | 19 ++++ common/tsconfig.tsbuildinfo | 1 + mcp-server/README.md | 16 ++++ mcp-server/package-lock.json | 12 +++ mcp-server/package.json | 1 + mcp-server/src/PenpotMcpServer.ts | 89 +++++++++++++++-- mcp-server/src/PluginTask.ts | 101 +++++++++++++++++--- mcp-server/src/tasks/PrintTextPluginTask.ts | 19 +--- mcp-server/src/tools/PrintTextTool.ts | 27 ++++-- penpot-plugin/package-lock.json | 29 ++++++ penpot-plugin/package.json | 3 +- penpot-plugin/src/main.ts | 23 ++++- penpot-plugin/src/plugin.ts | 68 ++++++++++--- 19 files changed, 572 insertions(+), 79 deletions(-) create mode 100644 common/package-lock.json create mode 100644 common/package.json create mode 100644 common/src/index.ts create mode 100644 common/src/types.ts create mode 100644 common/tsconfig.json create mode 100644 common/tsconfig.tsbuildinfo diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 7e87e77..86dacdd 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -1,31 +1,70 @@ -# Penpot MCP Project Overview +# Penpot MCP Project Overview - Updated ## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools. +This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication. ## Tech Stack - **Language**: TypeScript - **Runtime**: Node.js - **Framework**: MCP SDK (@modelcontextprotocol/sdk) -- **Build Tool**: TypeScript Compiler (tsc) +- **Build Tool**: TypeScript Compiler (tsc) + esbuild - **Package Manager**: npm +- **WebSocket**: ws library for real-time communication ## Project Structure ``` penpot-mcp/ -├── mcp-server/ # Main MCP server implementation +├── common/ # NEW: Shared type definitions +│ ├── src/ +│ │ ├── index.ts # Exports for shared types +│ │ └── types.ts # PluginTaskResult, request/response interfaces +│ └── package.json # @penpot-mcp/common package +├── mcp-server/ # Main MCP server implementation │ ├── src/ │ │ ├── index.ts # Main server entry point -│ │ ├── interfaces/ # Type definitions and contracts -│ │ │ └── Tool.ts # Tool interface definition +│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation +│ │ ├── PluginTask.ts # Now supports result promises +│ │ ├── tasks/ # Task implementations +│ │ │ └── PrintTextPluginTask.ts # Uses shared types │ │ └── tools/ # Tool implementations -│ │ └── HelloWorldTool.ts -│ ├── package.json # Dependencies and scripts -│ └── tsconfig.json # TypeScript configuration -└── penpot-plugin/ # Penpot plugin (currently empty) +│ │ ├── HelloWorldTool.ts +│ │ └── PrintTextTool.ts # Now waits for task completion +│ └── package.json # Includes @penpot-mcp/common dependency +└── penpot-plugin/ # Penpot plugin with response capability + ├── src/ + │ ├── main.ts # Enhanced WebSocket handling with response forwarding + │ └── plugin.ts # Now sends task responses back to server + └── package.json # Includes @penpot-mcp/common dependency ``` -## Key Components -- **PenpotMcpServer**: Main server class that manages tool registration and MCP protocol handling -- **Tool Interface**: Abstraction for all tool implementations -- **HelloWorldTool**: Example tool implementation demonstrating the pattern +## Key Components - Updated + +### Enhanced WebSocket Protocol +- **Request Format**: `{id: string, task: string, params: any}` +- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}` +- **Request/Response Correlation**: Using unique UUIDs for task tracking +- **Timeout Handling**: 30-second timeout with automatic cleanup +- **Type Safety**: Shared definitions via @penpot-mcp/common package + +### Core Classes +- **PenpotMcpServer**: Enhanced with pending task tracking and response handling +- **PluginTask**: Now creates result promises that resolve when plugin responds +- **Tool implementations**: Now properly await task completion and report results +- **Plugin handlers**: Send structured responses back to server + +### New Features +1. **Bidirectional Communication**: Plugin now responds with success/failure status +2. **Task Result Promises**: Every executePluginTask() sets and returns a promise +3. **Error Reporting**: Failed tasks properly report error messages to tools +4. **Shared Type Safety**: Common package ensures consistency across projects +5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit) +6. **Request Correlation**: Unique IDs match requests to responses + +## Protocol Flow +``` +LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API + ↑ ↓ + Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result +``` + +All components now properly handle the full request/response lifecycle with comprehensive error handling and type safety. diff --git a/README.md b/README.md index 6182928..21efa8a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,57 @@ # The Penpot MCP Server -The system consists of two main components: +This system enables LLMs to interact with Penpot design projects through a Model Context Protocol (MCP) server and plugin architecture. -1. **MCP Server** (`mcp-server/`): - - Runs the MCP server providing tools to an LLM for Penpot project interaction - - Runs a WebSocket server which accepts connections from the Penpot MCP Plugin, - establishing a communication channel between the plugin and the MCP server +## Architecture -2. **Penpot MCP Plugin** (`penpot-plugin/`): - - Establishes WebSocket connection to the MCP server - - Receives tasks from the MCP server, which it executes in the Penpot project, making - use of the Penpot Plugin API +The system consists of three main components: + +1. **Common Types** (`common/`): + - Shared TypeScript definitions for request/response protocol + - Ensures type safety across server and plugin components + - Defines `PluginTaskResult`, request/response interfaces, and task parameters + +2. **MCP Server** (`mcp-server/`): + - Provides MCP tools to LLMs for Penpot interaction + - Runs WebSocket server accepting connections from Penpot plugins + - Implements request/response correlation with unique task IDs + - Handles task timeouts and proper error reporting + +3. **Penpot Plugin** (`penpot-plugin/`): + - Connects to MCP server via WebSocket + - Executes tasks in Penpot using the Plugin API + - Sends structured responses back to server with success/failure status + +## Protocol Flow + +``` +LLM → MCP Server → WebSocket → Penpot Plugin → Penpot API + ↓ ↓ ↓ + Tool Call Task Request Execute Action + ↑ ↑ ↑ +LLM ← MCP Server ← WebSocket ← Penpot Plugin ← Result +``` + +### Request Format +```typescript +{ + id: string, // Unique UUID for correlation + task: string, // Task type (e.g., "printText") + params: object // Task-specific parameters +} +``` + +### Response Format +```typescript +{ + id: string, // Matching request ID + result: { + success: boolean, // Task completion status + error?: string, // Error message if failed + data?: any // Optional result data + } +} +``` ## Testing the Connection diff --git a/common/package-lock.json b/common/package-lock.json new file mode 100644 index 0000000..2c374d0 --- /dev/null +++ b/common/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@penpot-mcp/common", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@penpot-mcp/common", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/common/package.json b/common/package.json new file mode 100644 index 0000000..859f077 --- /dev/null +++ b/common/package.json @@ -0,0 +1,17 @@ +{ + "name": "@penpot-mcp/common", + "version": "1.0.0", + "description": "Shared type definitions and interfaces for Penpot MCP", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5.0.0" + }, + "files": [ + "dist/**/*" + ] +} diff --git a/common/src/index.ts b/common/src/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/common/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/common/src/types.ts b/common/src/types.ts new file mode 100644 index 0000000..6990fd0 --- /dev/null +++ b/common/src/types.ts @@ -0,0 +1,70 @@ +/** + * Result of a plugin task execution. + * + * Contains the outcome status of a task and any additional result data. + */ +export interface PluginTaskResult { + /** + * Whether the task completed successfully. + */ + success: boolean; + + /** + * Optional error message if the task failed. + */ + error?: string; + + /** + * Optional result data from the task execution. + */ + data?: any; +} + +/** + * Request message sent from server to plugin. + * + * Contains a unique identifier, task name, and parameters for execution. + */ +export interface PluginTaskRequest { + /** + * Unique identifier for request/response correlation. + */ + id: string; + + /** + * The name of the task to execute. + */ + task: string; + + /** + * The parameters for task execution. + */ + params: any; +} + +/** + * Response message sent from plugin back to server. + * + * Contains the original request ID and the execution result. + */ +export interface PluginTaskResponse { + /** + * Unique identifier matching the original request. + */ + id: string; + + /** + * The result of the task execution. + */ + result: PluginTaskResult; +} + +/** + * Parameters for the printText task. + */ +export interface PrintTextTaskParams { + /** + * The text to be displayed in Penpot. + */ + text: string; +} diff --git a/common/tsconfig.json b/common/tsconfig.json new file mode 100644 index 0000000..89b057e --- /dev/null +++ b/common/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/common/tsconfig.tsbuildinfo b/common/tsconfig.tsbuildinfo new file mode 100644 index 0000000..077a7c5 --- /dev/null +++ b/common/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./src/types.ts","./src/index.ts"],"fileIdsList":[[58]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"c8dc64b2cf93d7d61531720d107be359ffc67053f07d6376165fec669a93b96b","signature":"3b050a7e71b296b4225a93f67592868e7b17c27c84d2f7296efb58365ad7916b"},{"version":"17d28fc66e26fe5fe1910163d88bd09ca0848c4e07be5bff7a2a9ffb41188554","signature":"d5c19655468e29f60c871b21e73af8ebc653f736e7123ade916f22c4a5f80ce5"}],"root":[58,59],"options":{"composite":true,"declaration":true,"declarationMap":true,"esModuleInterop":true,"module":1,"outDir":"./dist","rootDir":"./src","skipLibCheck":true,"sourceMap":true,"strict":true,"target":9},"referencedMap":[[59,1]],"latestChangedDtsFile":"./dist/index.d.ts","version":"5.9.2"} \ No newline at end of file diff --git a/mcp-server/README.md b/mcp-server/README.md index ac44821..edc048f 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -111,3 +111,19 @@ For MCP clients that support HTTP transport directly, use: - Streamable HTTP for modern clients: `http://localhost:4401/mcp` - SSE for legacy clients: `http://localhost:4401/sse` +## Plugin Communication + +The server also runs a WebSocket server on port 8080 for communication with Penpot plugins: + +- **WebSocket endpoint**: `ws://localhost:8080` +- **Protocol**: Request/response with unique ID correlation +- **Timeout**: 30 seconds for task completion +- **Shared Types**: Uses `@penpot-mcp/common` package for type safety + +### WebSocket Protocol Features + +- **Request Correlation**: Each task has a unique UUID for matching responses +- **Structured Results**: Tasks return `{success: boolean, error?: string, data?: any}` +- **Timeout Handling**: Prevents hanging tasks with automatic cleanup +- **Type Safety**: Shared TypeScript definitions across server and plugin + diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 08674a4..52e63dd 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", + "@penpot-mcp/common": "file:../common", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "express": "^4.18.0", @@ -26,6 +27,13 @@ "typescript": "^5.0.0" } }, + "../common": { + "name": "@penpot-mcp/common", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -751,6 +759,10 @@ "node": ">= 0.6" } }, + "node_modules/@penpot-mcp/common": { + "resolved": "../common", + "link": true + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index 7c5da04..08fb6ad 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -22,6 +22,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", + "@penpot-mcp/common": "file:../common", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "express": "^4.18.0", diff --git a/mcp-server/src/PenpotMcpServer.ts b/mcp-server/src/PenpotMcpServer.ts index e550882..0582e9b 100644 --- a/mcp-server/src/PenpotMcpServer.ts +++ b/mcp-server/src/PenpotMcpServer.ts @@ -8,6 +8,7 @@ import { ToolInterface } from "./Tool"; import { HelloWorldTool } from "./tools/HelloWorldTool"; import { PrintTextTool } from "./tools/PrintTextTool"; import { PluginTask } from "./PluginTask"; +import { PluginTaskResponse, PluginTaskResult } from '@penpot-mcp/common'; /** * Penpot MCP server implementation with HTTP and SSE Transport Support @@ -17,6 +18,8 @@ export class PenpotMcpServer { private readonly tools: Map; private readonly wsServer: WebSocketServer; private readonly connectedClients: Set = new Set(); + private readonly pendingTasks: Map> = new Map(); + private readonly taskTimeouts: Map = new Map(); private app: any; // Express app private readonly port: number; @@ -228,7 +231,12 @@ export class PenpotMcpServer { ws.on("message", (data: Buffer) => { console.error("Received WebSocket message:", data.toString()); - // Protocol will be defined later + try { + const response: PluginTaskResponse = JSON.parse(data.toString()); + this.handlePluginTaskResponse(response); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } }); ws.on("close", () => { @@ -245,7 +253,52 @@ export class PenpotMcpServer { console.error("WebSocket server started on port 8080"); } - public executePluginTask(task: PluginTask) { + /** + * Handles responses from the plugin for completed tasks. + * + * Finds the pending task by ID and resolves or rejects its promise + * based on the execution result. + * + * @param response - The plugin task response containing ID and result + */ + private handlePluginTaskResponse(response: PluginTaskResponse): void { + const task = this.pendingTasks.get(response.id); + if (!task) { + console.error(`Received response for unknown task ID: ${response.id}`); + return; + } + + // Clear the timeout and remove the task from pending tasks + const timeoutHandle = this.taskTimeouts.get(response.id); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + this.taskTimeouts.delete(response.id); + } + this.pendingTasks.delete(response.id); + + // Resolve or reject the task's promise based on the result + if (response.result.success) { + task.resolveWithResult(response.result); + } else { + const error = new Error(response.result.error || 'Task execution failed'); + task.rejectWithError(error); + } + + console.error(`Task ${response.id} completed with success: ${response.result.success}`); + } + + /** + * Executes a plugin task by sending it to connected clients. + * + * Registers the task for result correlation and returns a promise + * that resolves when the plugin responds with the execution result. + * + * @param task - The plugin task to execute + * @throws Error if no plugin instances are connected or available + */ + public async executePluginTask( + task: PluginTask + ): Promise { // Check if there are connected clients if (this.connectedClients.size === 0) { throw new Error( @@ -253,19 +306,43 @@ export class PenpotMcpServer { ); } - // Send task to all connected clients - const taskMessage = JSON.stringify(task.toJSON()); + // Register the task for result correlation + this.pendingTasks.set(task.id, task); + + // Send task to all connected clients using the new request format + const requestMessage = JSON.stringify(task.toRequest()); let sentCount = 0; this.connectedClients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN - client.send(taskMessage); + client.send(requestMessage); sentCount++; } }); + if (sentCount === 0) { - throw new Error(`All connected plugin instances appear to be disconnected. No text was created.`); + // Clean up the pending task and timeout since we couldn't send it + this.pendingTasks.delete(task.id); + const timeoutHandle = this.taskTimeouts.get(task.id); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + this.taskTimeouts.delete(task.id); + } + throw new Error(`All connected plugin instances appear to be disconnected. Task could not be sent.`); } + + // Set up a timeout to reject the task if no response is received + const timeoutHandle = setTimeout(() => { + const pendingTask = this.pendingTasks.get(task.id); + if (pendingTask) { + this.pendingTasks.delete(task.id); + this.taskTimeouts.delete(task.id); + pendingTask.rejectWithError(new Error(`Task ${task.id} timed out after 30 seconds`)); + } + }, 30000); // 30 second timeout + + this.taskTimeouts.set(task.id, timeoutHandle); + console.error(`Sent task ${task.id} to ${sentCount} connected clients`); } /** diff --git a/mcp-server/src/PluginTask.ts b/mcp-server/src/PluginTask.ts index f682752..daaa42d 100644 --- a/mcp-server/src/PluginTask.ts +++ b/mcp-server/src/PluginTask.ts @@ -6,7 +6,24 @@ * * @template TParams - The strongly-typed parameters for this task */ -export abstract class PluginTask { +import { PluginTaskRequest, PluginTaskResult } from '@penpot-mcp/common'; +import { randomUUID } from 'crypto'; + +/** + * Base class for plugin tasks that are sent over WebSocket. + * + * Each task defines a specific operation for the plugin to execute + * along with strongly-typed parameters and request/response correlation. + * + * @template TParams - The strongly-typed parameters for this task + * @template TResult - The expected result type from task execution + */ +export abstract class PluginTask { + /** + * Unique identifier for request/response correlation. + */ + public readonly id: string; + /** * The name of the task to execute on the plugin side. */ @@ -17,7 +34,20 @@ export abstract class PluginTask { */ public readonly params: TParams; - private result?: Promise = undefined; + /** + * Promise that resolves when the task execution completes. + */ + private result?: Promise; + + /** + * Resolver function for the result promise. + */ + private resolveResult?: (result: TResult) => void; + + /** + * Rejector function for the result promise. + */ + private rejectResult?: (error: Error) => void; /** * Creates a new plugin task instance. @@ -26,26 +56,75 @@ export abstract class PluginTask { * @param params - The parameters for task execution */ constructor(task: string, params: TParams) { + this.id = randomUUID(); this.task = task; this.params = params; + this.setupResultPromise(); } /** - * Sets the result promise for this task. - * - * This can be used to track the outcome of the task execution. - * - * @param resultPromise - A promise that resolves to the task result + * Sets up the result promise and its resolvers. + * + * Creates a promise that can be resolved externally when + * the task result is received from the plugin. */ - setResult(resultPromise: Promise): void { - this.result = resultPromise; + private setupResultPromise(): void { + this.result = new Promise((resolve, reject) => { + this.resolveResult = resolve; + this.rejectResult = reject; + }); } /** - * Serializes the task to JSON for WebSocket transmission. + * Gets the result promise for this task. + * + * @returns Promise that resolves when the task execution completes */ - toJSON(): { task: string; params: TParams } { + getResultPromise(): Promise { + if (!this.result) { + throw new Error('Result promise not initialized'); + } + return this.result; + } + + /** + * Resolves the task with the given result. + * + * This method should be called when a task response is received + * from the plugin with matching ID. + * + * @param result - The task execution result + */ + resolveWithResult(result: TResult): void { + if (!this.resolveResult) { + throw new Error('Result promise not initialized'); + } + this.resolveResult(result); + } + + /** + * Rejects the task with the given error. + * + * This method should be called when task execution fails + * or times out. + * + * @param error - The error that occurred during task execution + */ + rejectWithError(error: Error): void { + if (!this.rejectResult) { + throw new Error('Result promise not initialized'); + } + this.rejectResult(error); + } + + /** + * Serializes the task to a request message for WebSocket transmission. + * + * @returns The request message containing ID, task name, and parameters + */ + toRequest(): PluginTaskRequest { return { + id: this.id, task: this.task, params: this.params, }; diff --git a/mcp-server/src/tasks/PrintTextPluginTask.ts b/mcp-server/src/tasks/PrintTextPluginTask.ts index 871a360..7048845 100644 --- a/mcp-server/src/tasks/PrintTextPluginTask.ts +++ b/mcp-server/src/tasks/PrintTextPluginTask.ts @@ -1,18 +1,5 @@ import { PluginTask } from "../PluginTask"; - -/** - * Parameters for the printText task. - */ -export class PrintTextPluginTaskParams { - /** - * The text to be displayed in Penpot. - */ - public readonly text: string; - - constructor(text: string) { - this.text = text; - } -} +import { PrintTextTaskParams, PluginTaskResult } from '@penpot-mcp/common'; /** * Task for printing/creating text in Penpot. @@ -20,13 +7,13 @@ export class PrintTextPluginTaskParams { * This task instructs the plugin to create a text element * at the viewport center and select it. */ -export class PrintTextPluginTask extends PluginTask { +export class PrintTextPluginTask extends PluginTask { /** * Creates a new print text task. * * @param params - The parameters containing the text to print */ - constructor(params: PrintTextPluginTaskParams) { + constructor(params: PrintTextTaskParams) { super("printText", params); } } diff --git a/mcp-server/src/tools/PrintTextTool.ts b/mcp-server/src/tools/PrintTextTool.ts index b8d9926..ad34443 100644 --- a/mcp-server/src/tools/PrintTextTool.ts +++ b/mcp-server/src/tools/PrintTextTool.ts @@ -4,7 +4,8 @@ import type { ToolResponse } from "../ToolResponse"; import { TextResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; -import { PrintTextPluginTask, PrintTextPluginTaskParams } from "../tasks/PrintTextPluginTask"; +import { PrintTextPluginTask } from "../tasks/PrintTextPluginTask"; +import { PrintTextTaskParams } from '@penpot-mcp/common'; /** * Arguments class for the PrintText tool with validation decorators. @@ -43,11 +44,25 @@ export class PrintTextTool extends Tool { } protected async executeCore(args: PrintTextArgs): Promise { - const taskParams = new PrintTextPluginTaskParams(args.text); + const taskParams: PrintTextTaskParams = { text: args.text }; const task = new PrintTextPluginTask(taskParams); - this.mcpServer.executePluginTask(task); - return new TextResponse( - `Successfully sent text creation task. Text "${args.text}" should now appear in Penpot.` - ); + + try { + await this.mcpServer.executePluginTask(task); + const result = await task.getResultPromise(); + + if (result.success) { + return new TextResponse( + `Successfully created text "${args.text}" in Penpot.` + ); + } else { + return new TextResponse( + `Failed to create text in Penpot: ${result.error || 'Unknown error'}` + ); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return new TextResponse(`Failed to execute text creation task: ${errorMessage}`); + } } } diff --git a/penpot-plugin/package-lock.json b/penpot-plugin/package-lock.json index c6145aa..f0102e4 100644 --- a/penpot-plugin/package-lock.json +++ b/penpot-plugin/package-lock.json @@ -8,15 +8,24 @@ "name": "penpot-plugin-starter-template", "version": "0.0.0", "dependencies": { + "@penpot-mcp/common": "file:../common", "@penpot/plugin-styles": "1.3.2", "@penpot/plugin-types": "1.3.2" }, "devDependencies": { + "prettier": "^3.0.0", "typescript": "^5.8.3", "vite": "^7.0.5", "vite-live-preview": "^0.3.2" } }, + "../common": { + "name": "@penpot-mcp/common", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/@commander-js/extra-typings": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", @@ -442,6 +451,10 @@ "node": ">=18" } }, + "node_modules/@penpot-mcp/common": { + "resolved": "../common", + "link": true + }, "node_modules/@penpot/plugin-styles": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@penpot/plugin-styles/-/plugin-styles-1.3.2.tgz", @@ -970,6 +983,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.45.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", diff --git a/penpot-plugin/package.json b/penpot-plugin/package.json index 2d15474..ee35f28 100644 --- a/penpot-plugin/package.json +++ b/penpot-plugin/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@penpot/plugin-styles": "1.3.2", - "@penpot/plugin-types": "1.3.2" + "@penpot/plugin-types": "1.3.2", + "@penpot-mcp/common": "file:../common" }, "devDependencies": { "prettier": "^3.0.0", diff --git a/penpot-plugin/src/main.ts b/penpot-plugin/src/main.ts index a749e95..958ea24 100644 --- a/penpot-plugin/src/main.ts +++ b/penpot-plugin/src/main.ts @@ -18,6 +18,20 @@ function updateConnectionStatus(status: string, isConnectedState: boolean): void } } +/** + * Sends a task response back to the MCP server via WebSocket. + * + * @param response - The response containing task ID and result + */ +function sendTaskResponse(response: any): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(response)); + console.log("Sent response to MCP server:", response); + } else { + console.error("WebSocket not connected, cannot send response"); + } +} + /** * Establishes a WebSocket connection to the MCP server. */ @@ -39,9 +53,9 @@ function connectToMcpServer(): void { ws.onmessage = (event) => { console.log("Received from MCP server:", event.data); try { - const message = JSON.parse(event.data); - // Forward the task to the plugin for execution - parent.postMessage(message, "*"); + const request = JSON.parse(event.data); + // Forward the task request to the plugin for execution + parent.postMessage(request, "*"); } catch (error) { console.error("Failed to parse WebSocket message:", error); } @@ -77,5 +91,8 @@ document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click" window.addEventListener("message", (event) => { if (event.data.source === "penpot") { document.body.dataset.theme = event.data.theme; + } else if (event.data.type === "task-response") { + // Forward task response back to MCP server + sendTaskResponse(event.data.response); } }); diff --git a/penpot-plugin/src/plugin.ts b/penpot-plugin/src/plugin.ts index 095631b..126ccb1 100644 --- a/penpot-plugin/src/plugin.ts +++ b/penpot-plugin/src/plugin.ts @@ -1,7 +1,7 @@ penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`); -// Handle both legacy string messages and new task-based messages -penpot.ui.onMessage((message) => { +// Handle both legacy string messages and new request-based messages +penpot.ui.onMessage((message) => { // Legacy string-based message handling if (typeof message === "string") { if (message === "create-text") { @@ -17,38 +17,47 @@ penpot.ui.onMessage((message) => { return; } - // New task-based message handling - if (typeof message === "object" && message.task) { - handlePluginTask(message); + // New request-based message handling + if (typeof message === "object" && message.task && message.id) { + handlePluginTaskRequest(message); } }); /** - * Handles plugin tasks received from the MCP server via WebSocket. + * Handles plugin task requests received from the MCP server via WebSocket. * - * @param taskMessage - The task message containing task type and parameters + * @param request - The task request containing ID, task type and parameters */ -function handlePluginTask(taskMessage: { task: string; params: any }): void { - console.log("Executing plugin task:", taskMessage.task, taskMessage.params); +function handlePluginTaskRequest(request: { id: string; task: string; params: any }): void { + console.log("Executing plugin task:", request.task, request.params); - switch (taskMessage.task) { + switch (request.task) { case "printText": - handlePrintTextTask(taskMessage.params); + handlePrintTextTask(request.id, request.params); break; default: - console.warn("Unknown plugin task:", taskMessage.task); + console.warn("Unknown plugin task:", request.task); + sendTaskResponse(request.id, { + success: false, + error: `Unknown task type: ${request.task}`, + }); } } /** * Handles the printText task by creating text in Penpot. * + * @param taskId - The unique ID of the task request * @param params - The parameters containing the text to create */ -function handlePrintTextTask(params: { text: string }): void { +function handlePrintTextTask(taskId: string, params: { text: string }): void { if (!params.text) { console.error("printText task requires 'text' parameter"); + sendTaskResponse(taskId, { + success: false, + error: "printText task requires 'text' parameter", + }); return; } @@ -64,14 +73,47 @@ function handlePrintTextTask(params: { text: string }): void { penpot.selection = [text]; console.log("Successfully created text:", params.text); + sendTaskResponse(taskId, { + success: true, + data: { textId: text.id }, + }); } else { console.error("Failed to create text element"); + sendTaskResponse(taskId, { + success: false, + error: "Failed to create text element", + }); } } catch (error) { console.error("Error creating text:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + sendTaskResponse(taskId, { + success: false, + error: `Error creating text: ${errorMessage}`, + }); } } +/** + * Sends a task response back to the MCP server. + * + * @param taskId - The unique ID of the original task request + * @param result - The task execution result + */ +function sendTaskResponse(taskId: string, result: { success: boolean; error?: string; data?: any }): void { + const response = { + type: "task-response", + response: { + id: taskId, + result: result, + }, + }; + + // Send to main.ts which will forward to MCP server via WebSocket + penpot.ui.sendMessage(response); + console.log("Sent task response:", response); +} + // Update the theme in the iframe penpot.on("themechange", (theme) => { penpot.ui.sendMessage({