mirror of
https://github.com/penpot/penpot-mcp.git
synced 2026-04-25 11:18:37 +00:00
Implement initial WebSocket interaction between MCP server and Penpot plugin
(Example: Writing and selecting a text object to the Penpot project)
This commit is contained in:
parent
16167e2758
commit
4a9700d445
@ -21,7 +21,7 @@
|
||||
## Documentation Style
|
||||
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
|
||||
- **Description Format**: Initial elliptical phrase defines *what* it is, followed by details
|
||||
- **Comment Style**: Start with lowercase for code blocks unless lengthy explanation with multiple sentences
|
||||
- **Comment Style**: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)
|
||||
|
||||
## Code Organization
|
||||
- **Separation of Concerns**: Interfaces in separate directory from implementations
|
||||
|
||||
@ -9,7 +9,7 @@ It currently includes a demonstration tool and provides a foundation for adding
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
## Installation & Setup
|
||||
@ -30,16 +30,19 @@ npm run build
|
||||
### 3. Run the Server
|
||||
|
||||
**Development Mode** (with TypeScript compilation):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Production Mode** (requires build first):
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
**With Custom Port**:
|
||||
|
||||
```bash
|
||||
npm start -- --port 8080
|
||||
# OR in development
|
||||
@ -49,19 +52,20 @@ node dist/index.js --port 8080
|
||||
```
|
||||
|
||||
**Available Options**:
|
||||
|
||||
- `--port, -p <number>`: Port number for the HTTP/SSE server (default: 4401)
|
||||
- `--help, -h`: Show help message
|
||||
|
||||
## Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm install` | Install all dependencies |
|
||||
| `npm run build` | Compile TypeScript to JavaScript |
|
||||
| `npm run start` | Start the built server |
|
||||
| `npm run dev` | Start in development mode with ts-node |
|
||||
| `npm run format` | Format all files with Prettier |
|
||||
| `npm run format:check` | Check if files are properly formatted |
|
||||
| Command | Description |
|
||||
| ---------------------- | -------------------------------------- |
|
||||
| `npm install` | Install all dependencies |
|
||||
| `npm run build` | Compile TypeScript to JavaScript |
|
||||
| `npm run start` | Start the built server |
|
||||
| `npm run dev` | Start in development mode with ts-node |
|
||||
| `npm run format` | Format all files with Prettier |
|
||||
| `npm run format:check` | Check if files are properly formatted |
|
||||
|
||||
## Claude Desktop Integration
|
||||
|
||||
@ -78,6 +82,7 @@ npm start
|
||||
```
|
||||
|
||||
By default, the server runs on port 4401 and provides:
|
||||
|
||||
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
|
||||
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
|
||||
|
||||
@ -88,6 +93,7 @@ For Claude Desktop integration, you'll need to use a proxy since Claude Desktop
|
||||
**Option A: Using mcp-remote (Recommended)**
|
||||
|
||||
Install mcp-remote globally if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g mcp-remote
|
||||
```
|
||||
@ -111,6 +117,7 @@ Add this to your Claude Desktop configuration file:
|
||||
**Option B: Direct HTTP Integration (for other MCP clients)**
|
||||
|
||||
For MCP clients that support HTTP transport directly, use:
|
||||
|
||||
- Modern clients: `http://localhost:4401/mcp`
|
||||
- Legacy clients: `http://localhost:4401/sse`
|
||||
|
||||
@ -172,17 +179,20 @@ export class MyCustomTool implements Tool {
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
- Ensure all dependencies are installed: `npm install`
|
||||
- Check that the project builds without errors: `npm run build`
|
||||
- Verify Node.js version is 18 or higher: `node --version`
|
||||
|
||||
### Claude Desktop Can't Find Server
|
||||
|
||||
- Verify the absolute path in your configuration is correct
|
||||
- Ensure the server is built (`npm run build`) before referencing `dist/index.js`
|
||||
- Check that the JSON configuration file is valid
|
||||
- Restart Claude Desktop completely after config changes
|
||||
|
||||
### Tools Not Available
|
||||
|
||||
- Confirm the server is listed in Claude Desktop's configuration
|
||||
- Check the Claude Desktop console/logs for any error messages
|
||||
- Verify tools are properly registered in the `registerTools()` method
|
||||
@ -190,6 +200,7 @@ export class MyCustomTool implements Tool {
|
||||
## Development
|
||||
|
||||
This project uses:
|
||||
|
||||
- **TypeScript** for type safety and better development experience
|
||||
- **Prettier** with 4-space indentation for consistent code formatting
|
||||
- **ESM modules** for modern JavaScript compatibility
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
|
||||
/**
|
||||
* Penpot MCP Server with HTTP and SSE Transport Support
|
||||
*
|
||||
*
|
||||
* This server implementation supports both modern Streamable HTTP and legacy SSE transports
|
||||
* instead of the traditional stdio transport. This provides better compatibility with
|
||||
* web-based MCP clients and allows for more flexible deployment scenarios.
|
||||
*
|
||||
*
|
||||
* Transport Endpoints:
|
||||
* - Modern Streamable HTTP: POST/GET/DELETE /mcp
|
||||
* - Legacy SSE: GET /sse and POST /messages
|
||||
* - WebSocket (for plugin communication): ws://localhost:8080
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* - Default port: node dist/index.js (runs on 4401)
|
||||
* - Custom port: node dist/index.js --port 8080
|
||||
@ -24,6 +24,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
||||
|
||||
import { Tool } from "./interfaces/Tool.js";
|
||||
import { HelloWorldTool } from "./tools/HelloWorldTool.js";
|
||||
import { ToolPrintText } from "./tools/ToolPrintText.js";
|
||||
|
||||
/**
|
||||
* Main MCP server implementation for Penpot integration.
|
||||
@ -42,12 +43,12 @@ class PenpotMcpServer {
|
||||
// Store transports for each session type
|
||||
private readonly transports = {
|
||||
streamable: {} as Record<string, any>, // StreamableHTTPServerTransport
|
||||
sse: {} as Record<string, any> // SSEServerTransport
|
||||
sse: {} as Record<string, any>, // SSEServerTransport
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new Penpot MCP server instance.
|
||||
*
|
||||
*
|
||||
* @param port - The port number for the HTTP/SSE server
|
||||
*/
|
||||
constructor(port: number = 4401) {
|
||||
@ -66,7 +67,7 @@ class PenpotMcpServer {
|
||||
|
||||
this.tools = new Map<string, Tool>();
|
||||
this.wsServer = new WebSocketServer({ port: 8080 });
|
||||
|
||||
|
||||
this.setupMcpHandlers();
|
||||
this.setupWebSocketHandlers();
|
||||
this.registerTools();
|
||||
@ -80,7 +81,8 @@ class PenpotMcpServer {
|
||||
*/
|
||||
private registerTools(): void {
|
||||
const toolInstances: Tool[] = [
|
||||
new HelloWorldTool()
|
||||
new HelloWorldTool(),
|
||||
new ToolPrintText(this.connectedClients),
|
||||
];
|
||||
|
||||
for (const tool of toolInstances) {
|
||||
@ -126,17 +128,17 @@ class PenpotMcpServer {
|
||||
*/
|
||||
private setupHttpEndpoints(): void {
|
||||
// Modern Streamable HTTP endpoint
|
||||
this.app.all('/mcp', async (req: any, res: any) => {
|
||||
this.app.all("/mcp", async (req: any, res: any) => {
|
||||
await this.handleStreamableHttpRequest(req, res);
|
||||
});
|
||||
|
||||
// Legacy SSE endpoint for older clients
|
||||
this.app.get('/sse', async (req: any, res: any) => {
|
||||
this.app.get("/sse", async (req: any, res: any) => {
|
||||
await this.handleSseConnection(req, res);
|
||||
});
|
||||
|
||||
// Legacy message endpoint for older clients
|
||||
this.app.post('/messages', async (req: any, res: any) => {
|
||||
this.app.post("/messages", async (req: any, res: any) => {
|
||||
await this.handleSseMessage(req, res);
|
||||
});
|
||||
}
|
||||
@ -148,12 +150,14 @@ class PenpotMcpServer {
|
||||
* streamable HTTP transport protocol.
|
||||
*/
|
||||
private async handleStreamableHttpRequest(req: any, res: any): Promise<void> {
|
||||
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
||||
const { StreamableHTTPServerTransport } = await import(
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp.js"
|
||||
);
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const { isInitializeRequest } = await import("@modelcontextprotocol/sdk/types.js");
|
||||
|
||||
// Check for existing session ID
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
let transport: any;
|
||||
|
||||
if (sessionId && this.transports.streamable[sessionId]) {
|
||||
@ -185,10 +189,10 @@ class PenpotMcpServer {
|
||||
} else {
|
||||
// Invalid request
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
message: "Bad Request: No valid session ID provided",
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
@ -207,15 +211,15 @@ class PenpotMcpServer {
|
||||
*/
|
||||
private async handleSseConnection(req: any, res: any): Promise<void> {
|
||||
const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js");
|
||||
|
||||
|
||||
// Create SSE transport for legacy clients
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
this.transports.sse[transport.sessionId] = transport;
|
||||
|
||||
|
||||
res.on("close", () => {
|
||||
delete this.transports.sse[transport.sessionId];
|
||||
});
|
||||
|
||||
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
|
||||
@ -228,11 +232,11 @@ class PenpotMcpServer {
|
||||
private async handleSseMessage(req: any, res: any): Promise<void> {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = this.transports.sse[sessionId];
|
||||
|
||||
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
} else {
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
res.status(400).send("No transport found for sessionId");
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,12 +278,12 @@ class PenpotMcpServer {
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Import express as ES module and setup HTTP endpoints
|
||||
const { default: express } = await import('express');
|
||||
const { default: express } = await import("express");
|
||||
this.app = express();
|
||||
this.app.use(express.json());
|
||||
|
||||
|
||||
this.setupHttpEndpoints();
|
||||
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.app.listen(this.port, () => {
|
||||
console.error(`Penpot MCP Server started successfully on port ${this.port}`);
|
||||
@ -305,7 +309,7 @@ async function main(): Promise<void> {
|
||||
let port = 4401; // Default port
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--port' || args[i] === '-p') {
|
||||
if (args[i] === "--port" || args[i] === "-p") {
|
||||
if (i + 1 < args.length) {
|
||||
const portArg = parseInt(args[i + 1], 10);
|
||||
if (!isNaN(portArg) && portArg > 0 && portArg <= 65535) {
|
||||
@ -314,10 +318,12 @@ async function main(): Promise<void> {
|
||||
console.error("Invalid port number. Using default port 4401.");
|
||||
}
|
||||
}
|
||||
} else if (args[i] === '--help' || args[i] === '-h') {
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
console.log("Usage: node dist/index.js [options]");
|
||||
console.log("Options:");
|
||||
console.log(" --port, -p <number> Port number for the HTTP/SSE server (default: 4401)");
|
||||
console.log(
|
||||
" --port, -p <number> Port number for the HTTP/SSE server (default: 4401)"
|
||||
);
|
||||
console.log(" --help, -h Show this help message");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
71
mcp-server/src/interfaces/PluginTask.ts
Normal file
71
mcp-server/src/interfaces/PluginTask.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @template TParams - The strongly-typed parameters for this task
|
||||
*/
|
||||
export abstract class PluginTask<TParams = any> {
|
||||
/**
|
||||
* The name of the task to execute on the plugin side.
|
||||
*/
|
||||
public readonly task: string;
|
||||
|
||||
/**
|
||||
* The parameters for this task execution.
|
||||
*/
|
||||
public readonly params: TParams;
|
||||
|
||||
/**
|
||||
* Creates a new plugin task instance.
|
||||
*
|
||||
* @param task - The name of the task to execute
|
||||
* @param params - The parameters for task execution
|
||||
*/
|
||||
constructor(task: string, params: TParams) {
|
||||
this.task = task;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the task to JSON for WebSocket transmission.
|
||||
*/
|
||||
toJSON(): { task: string; params: TParams } {
|
||||
return {
|
||||
task: this.task,
|
||||
params: this.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the printText task.
|
||||
*/
|
||||
export class PluginTaskPrintTextParams {
|
||||
/**
|
||||
* The text to be displayed in Penpot.
|
||||
*/
|
||||
public readonly text: string;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task for printing/creating text in Penpot.
|
||||
*
|
||||
* This task instructs the plugin to create a text element
|
||||
* at the viewport center and select it.
|
||||
*/
|
||||
export class PluginTaskPrintText extends PluginTask<PluginTaskPrintTextParams> {
|
||||
/**
|
||||
* Creates a new print text task.
|
||||
*
|
||||
* @param params - The parameters containing the text to print
|
||||
*/
|
||||
constructor(params: PluginTaskPrintTextParams) {
|
||||
super("printText", params);
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,7 @@ export interface Tool {
|
||||
* Metadata for schema generation from class properties.
|
||||
*/
|
||||
interface PropertyMetadata {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
type: "string" | "number" | "boolean" | "array" | "object";
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
@ -43,7 +43,7 @@ interface PropertyMetadata {
|
||||
*/
|
||||
export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
private _definition: MCPTool | undefined;
|
||||
|
||||
|
||||
constructor(private ArgsClass: new () => TArgs) {}
|
||||
|
||||
/**
|
||||
@ -70,13 +70,13 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
try {
|
||||
// Transform plain object to class instance
|
||||
const argsInstance = plainToClass(this.ArgsClass, args as object);
|
||||
|
||||
|
||||
// Validate using class-validator decorators
|
||||
const errors = await validate(argsInstance);
|
||||
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = this.formatValidationErrors(errors);
|
||||
throw new Error(`Validation failed: ${errorMessages.join(', ')}`);
|
||||
throw new Error(`Validation failed: ${errorMessages.join(", ")}`);
|
||||
}
|
||||
|
||||
// Call the type-safe implementation
|
||||
@ -98,14 +98,14 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
const required: string[] = [];
|
||||
|
||||
const propertyNames = this.getPropertyNames(instance);
|
||||
|
||||
|
||||
for (const propName of propertyNames) {
|
||||
const metadata = this.getPropertyMetadata(this.ArgsClass, propName);
|
||||
properties[propName] = {
|
||||
type: metadata.type,
|
||||
description: metadata.description,
|
||||
};
|
||||
|
||||
|
||||
if (metadata.required) {
|
||||
required.push(propName);
|
||||
}
|
||||
@ -125,14 +125,15 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
private getPropertyNames(instance: TArgs): string[] {
|
||||
const prototype = Object.getPrototypeOf(instance);
|
||||
const propertyNames: string[] = [];
|
||||
|
||||
|
||||
propertyNames.push(...Object.getOwnPropertyNames(instance));
|
||||
propertyNames.push(...Object.getOwnPropertyNames(prototype));
|
||||
|
||||
return propertyNames.filter(name =>
|
||||
name !== 'constructor' &&
|
||||
!name.startsWith('_') &&
|
||||
typeof (instance as any)[name] !== 'function'
|
||||
|
||||
return propertyNames.filter(
|
||||
(name) =>
|
||||
name !== "constructor" &&
|
||||
!name.startsWith("_") &&
|
||||
typeof (instance as any)[name] !== "function"
|
||||
);
|
||||
}
|
||||
|
||||
@ -140,40 +141,42 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
* Extracts property metadata from class-validator decorators.
|
||||
*/
|
||||
private getPropertyMetadata(target: any, propertyKey: string): PropertyMetadata {
|
||||
const validationMetadata = Reflect.getMetadata('class-validator:storage', target) || {};
|
||||
const validationMetadata = Reflect.getMetadata("class-validator:storage", target) || {};
|
||||
const constraints = validationMetadata.validationMetadatas || [];
|
||||
|
||||
|
||||
let isRequired = true;
|
||||
let type: PropertyMetadata['type'] = 'string';
|
||||
let type: PropertyMetadata["type"] = "string";
|
||||
let description = `${propertyKey} parameter`;
|
||||
|
||||
for (const constraint of constraints) {
|
||||
if (constraint.propertyName === propertyKey) {
|
||||
switch (constraint.type) {
|
||||
case 'isOptional':
|
||||
case "isOptional":
|
||||
isRequired = false;
|
||||
break;
|
||||
case 'isString':
|
||||
type = 'string';
|
||||
case "isString":
|
||||
type = "string";
|
||||
break;
|
||||
case 'isNumber':
|
||||
type = 'number';
|
||||
case "isNumber":
|
||||
type = "number";
|
||||
break;
|
||||
case 'isBoolean':
|
||||
type = 'boolean';
|
||||
case "isBoolean":
|
||||
type = "boolean";
|
||||
break;
|
||||
case 'isArray':
|
||||
type = 'array';
|
||||
case "isArray":
|
||||
type = "array";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback type inference
|
||||
if (propertyKey.toLowerCase().includes('count') ||
|
||||
propertyKey.toLowerCase().includes('number') ||
|
||||
propertyKey.toLowerCase().includes('amount')) {
|
||||
type = 'number';
|
||||
if (
|
||||
propertyKey.toLowerCase().includes("count") ||
|
||||
propertyKey.toLowerCase().includes("number") ||
|
||||
propertyKey.toLowerCase().includes("amount")
|
||||
) {
|
||||
type = "number";
|
||||
}
|
||||
|
||||
return { type, description, required: isRequired };
|
||||
@ -183,9 +186,9 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
* Formats validation errors into human-readable messages.
|
||||
*/
|
||||
private formatValidationErrors(errors: ValidationError[]): string[] {
|
||||
return errors.map(error => {
|
||||
return errors.map((error) => {
|
||||
const constraints = Object.values(error.constraints || {});
|
||||
return `${error.property}: ${constraints.join(', ')}`;
|
||||
return `${error.property}: ${constraints.join(", ")}`;
|
||||
});
|
||||
}
|
||||
|
||||
@ -207,5 +210,7 @@ export abstract class TypeSafeTool<TArgs extends object> implements Tool {
|
||||
*
|
||||
* @param args - The validated, strongly-typed arguments
|
||||
*/
|
||||
protected abstract executeTypeSafe(args: TArgs): Promise<{ content: Array<{ type: string; text: string }> }>;
|
||||
protected abstract executeTypeSafe(
|
||||
args: TArgs
|
||||
): Promise<{ content: Array<{ type: string; text: string }> }>;
|
||||
}
|
||||
|
||||
@ -41,7 +41,9 @@ export class HelloWorldTool extends TypeSafeTool<HelloWorldArgs> {
|
||||
*
|
||||
* @param args - The validated HelloWorldArgs instance
|
||||
*/
|
||||
protected async executeTypeSafe(args: HelloWorldArgs): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||||
protected async executeTypeSafe(
|
||||
args: HelloWorldArgs
|
||||
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
116
mcp-server/src/tools/ToolPrintText.ts
Normal file
116
mcp-server/src/tools/ToolPrintText.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { IsString, IsNotEmpty } from "class-validator";
|
||||
import { TypeSafeTool } from "../interfaces/Tool.js";
|
||||
import { PluginTaskPrintText, PluginTaskPrintTextParams } from "../interfaces/PluginTask.js";
|
||||
import "reflect-metadata";
|
||||
|
||||
/**
|
||||
* Arguments class for the PrintText tool with validation decorators.
|
||||
*/
|
||||
export class PrintTextArgs {
|
||||
/**
|
||||
* The text to create in Penpot.
|
||||
*/
|
||||
@IsString({ message: "Text must be a string" })
|
||||
@IsNotEmpty({ message: "Text cannot be empty" })
|
||||
text!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for creating text elements in Penpot via WebSocket communication.
|
||||
*
|
||||
* This tool sends a PluginTaskPrintText to connected plugin instances,
|
||||
* instructing them to create and position text elements in the canvas.
|
||||
*/
|
||||
export class ToolPrintText extends TypeSafeTool<PrintTextArgs> {
|
||||
private connectedClients: Set<any>; // WebSocket clients
|
||||
|
||||
/**
|
||||
* Creates a new PrintText tool instance.
|
||||
*
|
||||
* @param connectedClients - Set of connected WebSocket clients
|
||||
*/
|
||||
constructor(connectedClients: Set<any>) {
|
||||
super(PrintTextArgs);
|
||||
this.connectedClients = connectedClients;
|
||||
}
|
||||
|
||||
protected getToolName(): string {
|
||||
return "print_text";
|
||||
}
|
||||
|
||||
protected getToolDescription(): string {
|
||||
return "Creates text in Penpot at the viewport center and selects it";
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the print text functionality by sending a task to connected plugins.
|
||||
*
|
||||
* This method creates a PluginTaskPrintText and broadcasts it to all
|
||||
* connected WebSocket clients for execution.
|
||||
*
|
||||
* @param args - The validated PrintTextArgs instance
|
||||
*/
|
||||
protected async executeTypeSafe(
|
||||
args: PrintTextArgs
|
||||
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||||
try {
|
||||
// Create the plugin task
|
||||
const taskParams = new PluginTaskPrintTextParams(args.text);
|
||||
const task = new PluginTaskPrintText(taskParams);
|
||||
|
||||
// Check if there are connected clients
|
||||
if (this.connectedClients.size === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Send task to all connected clients
|
||||
const taskMessage = JSON.stringify(task.toJSON());
|
||||
let sentCount = 0;
|
||||
|
||||
this.connectedClients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
client.send(taskMessage);
|
||||
sentCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (sentCount === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `All connected plugin instances appear to be disconnected. No text was created.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully sent text creation task to ${sentCount} connected plugin instance(s). Text "${args.text}" should now appear in Penpot.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to create text in Penpot: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,13 @@ function connectToMcpServer(): void {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log("Received from MCP server:", event.data);
|
||||
// Protocol will be defined later
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
// Forward the task to the plugin for execution
|
||||
parent.postMessage(message, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
@ -1,17 +1,76 @@
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`);
|
||||
|
||||
penpot.ui.onMessage<string>((message) => {
|
||||
if (message === "create-text") {
|
||||
const text = penpot.createText("Hello world!");
|
||||
// Handle both legacy string messages and new task-based messages
|
||||
penpot.ui.onMessage<string | { task: string; params: any }>((message) => {
|
||||
// Legacy string-based message handling
|
||||
if (typeof message === "string") {
|
||||
if (message === "create-text") {
|
||||
const text = penpot.createText("Hello world!");
|
||||
|
||||
if (text) {
|
||||
text.x = penpot.viewport.center.x;
|
||||
text.y = penpot.viewport.center.y;
|
||||
|
||||
penpot.selection = [text];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// New task-based message handling
|
||||
if (typeof message === "object" && message.task) {
|
||||
handlePluginTask(message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles plugin tasks received from the MCP server via WebSocket.
|
||||
*
|
||||
* @param taskMessage - The task message containing task type and parameters
|
||||
*/
|
||||
function handlePluginTask(taskMessage: { task: string; params: any }): void {
|
||||
console.log("Executing plugin task:", taskMessage.task, taskMessage.params);
|
||||
|
||||
switch (taskMessage.task) {
|
||||
case "printText":
|
||||
handlePrintTextTask(taskMessage.params);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Unknown plugin task:", taskMessage.task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the printText task by creating text in Penpot.
|
||||
*
|
||||
* @param params - The parameters containing the text to create
|
||||
*/
|
||||
function handlePrintTextTask(params: { text: string }): void {
|
||||
if (!params.text) {
|
||||
console.error("printText task requires 'text' parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = penpot.createText(params.text);
|
||||
|
||||
if (text) {
|
||||
// Center the text in the viewport
|
||||
text.x = penpot.viewport.center.x;
|
||||
text.y = penpot.viewport.center.y;
|
||||
|
||||
// Select the newly created text
|
||||
penpot.selection = [text];
|
||||
|
||||
console.log("Successfully created text:", params.text);
|
||||
} else {
|
||||
console.error("Failed to create text element");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating text:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update the theme in the iframe
|
||||
penpot.on("themechange", (theme) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user