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:
Dominik Jain 2025-09-10 21:38:36 +02:00
parent 16167e2758
commit 4a9700d445
9 changed files with 350 additions and 74 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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: [
{

View 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}`,
},
],
};
}
}
}

View File

@ -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 = () => {

View File

@ -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) => {