Establish return channel when executing plugin tasks

and package 'common' for representations used in both subprojects
This commit is contained in:
Dominik Jain 2025-09-12 15:40:17 +02:00
parent 283f01b0ac
commit b7d1171654
19 changed files with 572 additions and 79 deletions

View File

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

View File

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

29
common/package-lock.json generated Normal file
View File

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

17
common/package.json Normal file
View File

@ -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/**/*"
]
}

1
common/src/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './types';

70
common/src/types.ts Normal file
View File

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

19
common/tsconfig.json Normal file
View File

@ -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"]
}

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -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<string, ToolInterface>;
private readonly wsServer: WebSocketServer;
private readonly connectedClients: Set<WebSocket> = new Set();
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = 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<TResult extends PluginTaskResult>(
task: PluginTask<any, TResult>
): Promise<void> {
// 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`);
}
/**

View File

@ -6,7 +6,24 @@
*
* @template TParams - The strongly-typed parameters for this task
*/
export abstract class PluginTask<TParams = any, TResult = any> {
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<TParams = any, TResult extends PluginTaskResult = PluginTaskResult> {
/**
* 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<TParams = any, TResult = any> {
*/
public readonly params: TParams;
private result?: Promise<TResult> = undefined;
/**
* Promise that resolves when the task execution completes.
*/
private result?: Promise<TResult>;
/**
* 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<TParams = any, TResult = any> {
* @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<TResult>): void {
this.result = resultPromise;
private setupResultPromise(): void {
this.result = new Promise<TResult>((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<TResult> {
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,
};

View File

@ -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<PrintTextPluginTaskParams> {
export class PrintTextPluginTask extends PluginTask<PrintTextTaskParams, PluginTaskResult> {
/**
* Creates a new print text task.
*
* @param params - The parameters containing the text to print
*/
constructor(params: PrintTextPluginTaskParams) {
constructor(params: PrintTextTaskParams) {
super("printText", params);
}
}

View File

@ -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<PrintTextArgs> {
}
protected async executeCore(args: PrintTextArgs): Promise<ToolResponse> {
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}`);
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<string | { task: string; params: any }>((message) => {
// Handle both legacy string messages and new request-based messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
// Legacy string-based message handling
if (typeof message === "string") {
if (message === "create-text") {
@ -17,38 +17,47 @@ penpot.ui.onMessage<string | { task: string; params: any }>((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({