mirror of
https://github.com/penpot/penpot-mcp.git
synced 2026-04-25 11:18:37 +00:00
Establish return channel when executing plugin tasks
and package 'common' for representations used in both subprojects
This commit is contained in:
parent
283f01b0ac
commit
b7d1171654
@ -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.
|
||||
|
||||
59
README.md
59
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
|
||||
|
||||
|
||||
29
common/package-lock.json
generated
Normal file
29
common/package-lock.json
generated
Normal 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
17
common/package.json
Normal 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
1
common/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './types';
|
||||
70
common/src/types.ts
Normal file
70
common/src/types.ts
Normal 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
19
common/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
common/tsconfig.tsbuildinfo
Normal file
1
common/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
||||
|
||||
12
mcp-server/package-lock.json
generated
12
mcp-server/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
penpot-plugin/package-lock.json
generated
29
penpot-plugin/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user