Refactoring

* Handle plugin task execution centrally in MCP server
 * Centrally handle exceptions in Tool base class, returning an error message
This commit is contained in:
Dominik Jain 2025-09-12 12:58:50 +02:00
parent 472dcd7890
commit 6636544f88
5 changed files with 68 additions and 68 deletions

View File

@ -20,11 +20,12 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { WebSocketServer, WebSocket } from "ws";
import { WebSocket, WebSocketServer } from "ws";
import { ToolInterface } from "./interfaces/Tool.js";
import { HelloWorldTool } from "./tools/HelloWorldTool.js";
import { PrintTextTool } from "./tools/PrintTextTool";
import { PrintTextTool } from "./tools/PrintTextTool.js";
import { PluginTask } from "./interfaces/PluginTask.js";
/**
* Main MCP server implementation for Penpot integration.
@ -32,7 +33,7 @@ import { PrintTextTool } from "./tools/PrintTextTool";
* This server manages tool registration and execution using a clean
* abstraction pattern that allows for easy extension with new tools.
*/
class PenpotMcpServer {
export class PenpotMcpServer {
private readonly server: Server;
private readonly tools: Map<string, ToolInterface>;
private readonly wsServer: WebSocketServer;
@ -80,7 +81,7 @@ class PenpotMcpServer {
* the internal registry for later execution.
*/
private registerTools(): void {
const toolInstances: ToolInterface[] = [new HelloWorldTool(), new PrintTextTool(this.connectedClients)];
const toolInstances: ToolInterface[] = [new HelloWorldTool(this), new PrintTextTool(this)];
for (const tool of toolInstances) {
this.tools.set(tool.definition.name, tool);
@ -265,6 +266,29 @@ class PenpotMcpServer {
console.error("WebSocket server started on port 8080");
}
public executePluginTask(task: PluginTask) {
// Check if there are connected clients
if (this.connectedClients.size === 0) {
throw new Error(
`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) {
throw new Error(`All connected plugin instances appear to be disconnected. No text was created.`);
}
}
/**
* Starts the MCP server using HTTP and SSE transports.
*

View File

@ -6,7 +6,7 @@
*
* @template TParams - The strongly-typed parameters for this task
*/
export abstract class PluginTask<TParams = any> {
export abstract class PluginTask<TParams = any, TResult = any> {
/**
* The name of the task to execute on the plugin side.
*/
@ -17,6 +17,8 @@ export abstract class PluginTask<TParams = any> {
*/
public readonly params: TParams;
private result?: Promise<TResult> = undefined;
/**
* Creates a new plugin task instance.
*
@ -28,6 +30,17 @@ export abstract class PluginTask<TParams = any> {
this.params = params;
}
/**
* 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
*/
setResult(resultPromise: Promise<TResult>): void {
this.result = resultPromise;
}
/**
* Serializes the task to JSON for WebSocket transmission.
*/

View File

@ -2,7 +2,8 @@ import { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
import { validate, ValidationError } from "class-validator";
import { plainToClass } from "class-transformer";
import "reflect-metadata";
import { ToolResponse } from "./ToolResponse";
import { TextResponse, ToolResponse } from "./ToolResponse.js";
import type { PenpotMcpServer } from "../index.js";
/**
* Base interface for MCP tool implementations.
@ -45,7 +46,10 @@ interface PropertyMetadata {
export abstract class Tool<TArgs extends object> implements ToolInterface {
private _definition: MCPTool | undefined;
constructor(private ArgsClass: new () => TArgs) {}
protected constructor(
protected mcpServer: PenpotMcpServer,
private ArgsClass: new () => TArgs
) {}
/**
* Gets the tool definition with automatically generated JSON schema.
@ -69,24 +73,20 @@ export abstract class Tool<TArgs extends object> implements ToolInterface {
*/
async execute(args: unknown): Promise<ToolResponse> {
try {
// Transform plain object to class instance
// transform plain object to class instance
const argsInstance = plainToClass(this.ArgsClass, args as object);
// Validate using class-validator decorators
// 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(", ")}`);
}
// Call the type-safe implementation
// execute the actual tool logic
return await this.executeCore(argsInstance);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Tool execution failed: ${String(error)}`);
return new TextResponse(`Tool execution failed: ${String(error)}`);
}
}

View File

@ -3,6 +3,7 @@ import { Tool } from "../interfaces/Tool.js";
import "reflect-metadata";
import type { ToolResponse } from "../interfaces/ToolResponse.js";
import { TextResponse } from "../interfaces/ToolResponse.js";
import { PenpotMcpServer } from "../index";
/**
* Arguments class for the HelloWorld tool with validation decorators.
@ -23,8 +24,11 @@ export class HelloWorldArgs {
* type safety through the protected executeTypeSafe method.
*/
export class HelloWorldTool extends Tool<HelloWorldArgs> {
constructor() {
super(HelloWorldArgs);
/**
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, HelloWorldArgs);
}
protected getToolName(): string {

View File

@ -4,6 +4,7 @@ import { PluginTaskPrintText, PluginTaskPrintTextParams } from "../interfaces/Pl
import type { ToolResponse } from "../interfaces/ToolResponse.js";
import { TextResponse } from "../interfaces/ToolResponse.js";
import "reflect-metadata";
import { PenpotMcpServer } from "../index.js";
/**
* Arguments class for the PrintText tool with validation decorators.
@ -24,16 +25,13 @@ export class PrintTextArgs {
* instructing them to create and position text elements in the canvas.
*/
export class PrintTextTool extends Tool<PrintTextArgs> {
private connectedClients: Set<any>; // WebSocket clients
/**
* Creates a new PrintText tool instance.
*
* @param connectedClients - Set of connected WebSocket clients
* @param mcpServer - The MCP server instance
*/
constructor(connectedClients: Set<any>) {
super(PrintTextArgs);
this.connectedClients = connectedClients;
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, PrintTextArgs);
}
protected getToolName(): string {
@ -44,51 +42,12 @@ export class PrintTextTool extends Tool<PrintTextArgs> {
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 executeCore(args: PrintTextArgs): Promise<ToolResponse> {
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 new TextResponse(
`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 new TextResponse(
`All connected plugin instances appear to be disconnected. No text was created.`
);
}
return new TextResponse(
`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 new TextResponse(`Failed to create text in Penpot: ${errorMessage}`);
}
const taskParams = new PluginTaskPrintTextParams(args.text);
const task = new PluginTaskPrintText(taskParams);
this.mcpServer.executePluginTask(task);
return new TextResponse(
`Successfully sent text creation task. Text "${args.text}" should now appear in Penpot.`
);
}
}