diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md index 9810270..ff271b0 100644 --- a/.serena/memories/code_style_conventions.md +++ b/.serena/memories/code_style_conventions.md @@ -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 diff --git a/mcp-server/README.md b/mcp-server/README.md index 3475673..f6baf6d 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -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 `: 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 diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index e1fc1fc..65fbf49 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -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, // StreamableHTTPServerTransport - sse: {} as Record // SSEServerTransport + sse: {} as Record, // 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(); 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 { - 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 { 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 { 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 { // 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 { 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 { 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 Port number for the HTTP/SSE server (default: 4401)"); + console.log( + " --port, -p Port number for the HTTP/SSE server (default: 4401)" + ); console.log(" --help, -h Show this help message"); process.exit(0); } diff --git a/mcp-server/src/interfaces/PluginTask.ts b/mcp-server/src/interfaces/PluginTask.ts new file mode 100644 index 0000000..534a394 --- /dev/null +++ b/mcp-server/src/interfaces/PluginTask.ts @@ -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 { + /** + * 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 { + /** + * Creates a new print text task. + * + * @param params - The parameters containing the text to print + */ + constructor(params: PluginTaskPrintTextParams) { + super("printText", params); + } +} diff --git a/mcp-server/src/interfaces/Tool.ts b/mcp-server/src/interfaces/Tool.ts index a34d54e..dcff4cb 100644 --- a/mcp-server/src/interfaces/Tool.ts +++ b/mcp-server/src/interfaces/Tool.ts @@ -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 implements Tool { private _definition: MCPTool | undefined; - + constructor(private ArgsClass: new () => TArgs) {} /** @@ -70,13 +70,13 @@ export abstract class TypeSafeTool 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 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 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 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 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 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 }> }>; } diff --git a/mcp-server/src/tools/HelloWorldTool.ts b/mcp-server/src/tools/HelloWorldTool.ts index d248971..0de3bb1 100644 --- a/mcp-server/src/tools/HelloWorldTool.ts +++ b/mcp-server/src/tools/HelloWorldTool.ts @@ -41,7 +41,9 @@ export class HelloWorldTool extends TypeSafeTool { * * @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: [ { diff --git a/mcp-server/src/tools/ToolPrintText.ts b/mcp-server/src/tools/ToolPrintText.ts new file mode 100644 index 0000000..d919990 --- /dev/null +++ b/mcp-server/src/tools/ToolPrintText.ts @@ -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 { + private connectedClients: Set; // WebSocket clients + + /** + * Creates a new PrintText tool instance. + * + * @param connectedClients - Set of connected WebSocket clients + */ + constructor(connectedClients: Set) { + 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}`, + }, + ], + }; + } + } +} diff --git a/penpot-plugin/src/main.ts b/penpot-plugin/src/main.ts index 3983206..ae05b16 100644 --- a/penpot-plugin/src/main.ts +++ b/penpot-plugin/src/main.ts @@ -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 = () => { diff --git a/penpot-plugin/src/plugin.ts b/penpot-plugin/src/plugin.ts index 2af6816..8aa5ca7 100644 --- a/penpot-plugin/src/plugin.ts +++ b/penpot-plugin/src/plugin.ts @@ -1,17 +1,76 @@ penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`); -penpot.ui.onMessage((message) => { - if (message === "create-text") { - const text = penpot.createText("Hello world!"); +// Handle both legacy string messages and new task-based messages +penpot.ui.onMessage((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) => {