diff --git a/mcp-server/src/PenpotMcpServer.ts b/mcp-server/src/PenpotMcpServer.ts index f57ba25..c1abd96 100644 --- a/mcp-server/src/PenpotMcpServer.ts +++ b/mcp-server/src/PenpotMcpServer.ts @@ -1,264 +1,130 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { ToolInterface } from "./Tool"; import { HelloWorldTool } from "./tools/HelloWorldTool"; import { PrintTextTool } from "./tools/PrintTextTool"; import { ExecuteCodeTool } from "./tools/ExecuteCodeTool"; import { PluginBridge } from "./PluginBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; import { createLogger } from "./logger"; +import { Tool } from "./Tool"; -/** - * Penpot MCP server implementation with HTTP and SSE Transport Support - */ export class PenpotMcpServer { private readonly logger = createLogger("PenpotMcpServer"); - private readonly server: Server; - private readonly tools: Map; - private readonly configLoader: ConfigurationLoader; - private app: any; // Express app + private readonly server: McpServer; + private readonly tools: Map>; + public readonly configLoader: ConfigurationLoader; + private app: any; private readonly port: number; public readonly pluginBridge: PluginBridge; - // Store transports for each session type private readonly transports = { - streamable: {} as Record, // StreamableHTTPServerTransport - sse: {} as Record, // SSEServerTransport + streamable: {} as Record, + sse: {} as Record, }; - /** - * Creates a new Penpot MCP server instance. - * - * @param port - The port number for the HTTP/SSE server - * @param webSocketPort - The port number for the plugin bridge's WebSocket server - */ constructor(port: number = 4401, webSocketPort: number = 8080) { + this.configLoader = new ConfigurationLoader(); this.port = port; - this.server = new Server( + + const instructions = this.configLoader.getInitialInstructions(); + this.logger.info("Instructions: %s", instructions ?? ""); + this.server = new McpServer( { name: "penpot-mcp-server", version: "1.0.0", }, { - capabilities: { - tools: {}, - }, + instructions: instructions, } ); - this.tools = new Map(); - this.configLoader = new ConfigurationLoader(); + this.tools = new Map>(); this.pluginBridge = new PluginBridge(webSocketPort); - this.setupMcpHandlers(); this.registerTools(); } - /** - * Registers all available tools with the server. - * - * This method instantiates tool implementations and adds them to - * the internal registry for later execution. - */ private registerTools(): void { - const toolInstances: ToolInterface[] = [new HelloWorldTool(this), new PrintTextTool(this), new ExecuteCodeTool(this)]; + const toolInstances: Tool[] = [ + new HelloWorldTool(this), + new PrintTextTool(this), + new ExecuteCodeTool(this), + ]; for (const tool of toolInstances) { - this.tools.set(tool.definition.name, tool); - } - } + const toolName = tool.getToolName(); + this.tools.set(toolName, tool); - /** - * Sets up the MCP protocol request handlers. - * - * Configures handlers for tool listing and execution requests - * according to the MCP specification. - */ - private setupMcpHandlers(): void { - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: Array.from(this.tools.values()).map((tool) => tool.definition), - }; - }); - - this.server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { - const { name, arguments: args } = request.params; - - const tool = this.tools.get(name); - if (!tool) { - throw new Error(`Tool "${name}" not found`); - } - - try { - return await tool.execute(args); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - throw new Error(`Tool execution failed: ${errorMessage}`); - } - }); - } - - /** - * Sets up HTTP endpoints for modern Streamable HTTP and legacy SSE transports. - * - * Provides backwards compatibility by supporting both transport mechanisms - * for different client capabilities. - */ - private setupHttpEndpoints(): void { - // Modern Streamable HTTP endpoint - 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) => { - await this.handleSseConnection(req, res); - }); - - // Legacy message endpoint for older clients - this.app.post("/messages", async (req: any, res: any) => { - await this.handleSseMessage(req, res); - }); - } - - /** - * Handles Streamable HTTP requests for modern MCP clients. - * - * Provides session management and request routing for the new - * streamable HTTP transport protocol. - */ - private async handleStreamableHttpRequest(req: any, res: any): Promise { - 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; - let transport: any; - - if (sessionId && this.transports.streamable[sessionId]) { - // Reuse existing transport - transport = this.transports.streamable[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId: string) => { - // Store the transport by session ID - this.transports.streamable[sessionId] = transport; + // Register each tool with McpServer + this.logger.info(`Registering tool: ${toolName}`); + this.server.registerTool( + toolName, + { + description: tool.getToolDescription(), + inputSchema: tool.getInputSchema(), }, - // DNS rebinding protection is disabled by default for backwards compatibility - // If running locally, consider enabling: - // enableDnsRebindingProtection: true, - // allowedHosts: ['127.0.0.1'], - }); - - // Clean up transport when closed - transport.onclose = () => { - if (transport.sessionId) { - delete this.transports.streamable[transport.sessionId]; + async (args) => { + return tool.execute(args); } - }; - - // Connect to the MCP server - await this.server.connect(transport); - } else { - // Invalid request - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: No valid session ID provided", - }, - id: null, - }); - return; + ); } - - // Handle the request - await transport.handleRequest(req, res, req.body); } - /** - * Handles SSE connection establishment for legacy MCP clients. - * - * Creates and manages Server-Sent Events transport for older - * clients that don't support the streamable HTTP protocol. - */ - private async handleSseConnection(req: any, res: any): Promise { - const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js"); + private setupHttpEndpoints(): void { + this.app.all("/mcp", async (req: any, res: any) => { + const { randomUUID } = await import("node:crypto"); - // Create SSE transport for legacy clients - const transport = new SSEServerTransport("/messages", res); - this.transports.sse[transport.sessionId] = transport; + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let transport: StreamableHTTPServerTransport; - res.on("close", () => { - delete this.transports.sse[transport.sessionId]; + if (sessionId && this.transports.streamable[sessionId]) { + transport = this.transports.streamable[sessionId]; + } else { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id: string) => { + this.transports.streamable[id] = transport; + }, + }); + + transport.onclose = () => { + if (transport.sessionId) { + delete this.transports.streamable[transport.sessionId]; + } + }; + + await this.server.connect(transport); + } + + await transport.handleRequest(req, res, req.body); }); - await this.server.connect(transport); - } + this.app.get("/sse", async (_req: any, res: any) => { + const transport = new SSEServerTransport("/messages", res); + this.transports.sse[transport.sessionId] = transport; - /** - * Handles POST message requests for legacy SSE clients. - * - * Routes messages to the appropriate SSE transport based on - * the provided session identifier. - */ - private async handleSseMessage(req: any, res: any): Promise { - const sessionId = req.query.sessionId as string; - const transport = this.transports.sse[sessionId]; + res.on("close", () => { + delete this.transports.sse[transport.sessionId]; + }); - if (transport) { - await transport.handlePostMessage(req, res, req.body); - } else { - res.status(400).send("No transport found for sessionId"); - } - } + await this.server.connect(transport); + }); - /** - * Starts the MCP server using HTTP and SSE transports. - * - * This method establishes the HTTP server and begins listening - * for both modern and legacy MCP protocol connections. - */ - /** - * Displays initial instructions from the configuration. - * - * Loads and logs the initial instructions for the MCP server, - * providing helpful information to users about available capabilities - * and usage guidelines. - */ - private displayInitialInstructions(): void { - const initialInstructions = this.configLoader.getInitialInstructions(); - - if (initialInstructions) { - this.logger.info("=".repeat(80)); - this.logger.info("INITIAL INSTRUCTIONS"); - this.logger.info("=".repeat(80)); - - // Split instructions by lines and log each one separately for better formatting - const lines = initialInstructions.split('\n'); - for (const line of lines) { - this.logger.info(line); + this.app.post("/messages", async (req: any, res: any) => { + 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"); } - - this.logger.info("=".repeat(80)); - } - } - - /** - * Gets the configuration loader instance. - * - * @returns The configuration loader for accessing prompts and settings - */ - public getConfigurationLoader(): ConfigurationLoader { - return this.configLoader; + }); } async start(): Promise { - // Import express as ES module and setup HTTP endpoints const { default: express } = await import("express"); this.app = express(); this.app.use(express.json()); @@ -267,14 +133,10 @@ export class PenpotMcpServer { return new Promise((resolve) => { this.app.listen(this.port, () => { - this.logger.info(`Penpot MCP Server started successfully on port ${this.port}`); + this.logger.info(`Penpot MCP Server started on port ${this.port}`); this.logger.info(`Modern Streamable HTTP endpoint: http://localhost:${this.port}/mcp`); this.logger.info(`Legacy SSE endpoint: http://localhost:${this.port}/sse`); - this.logger.info("WebSocket server is listening on ws://localhost:8080"); - - // Display initial instructions if configured - this.displayInitialInstructions(); - + this.logger.info("WebSocket server is on ws://localhost:8080"); resolve(); }); }); diff --git a/mcp-server/src/Tool.ts b/mcp-server/src/Tool.ts index 41bdaa8..cf66379 100644 --- a/mcp-server/src/Tool.ts +++ b/mcp-server/src/Tool.ts @@ -1,38 +1,17 @@ +import { z } from "zod"; import { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js"; import { validate, ValidationError } from "class-validator"; import { plainToClass } from "class-transformer"; import "reflect-metadata"; import { TextResponse, ToolResponse } from "./ToolResponse"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +import { createLogger } from "./logger"; /** - * Base interface for MCP tool implementations. - * - * This interface maintains compatibility with the MCP protocol. - * Most implementations should extend the Tool abstract class instead. + * An empty arguments class for tools that do not require any parameters. */ -export interface ToolInterface { - /** - * The tool's unique identifier and metadata definition. - */ - readonly definition: MCPTool; - - /** - * Executes the tool's primary functionality with provided arguments. - * - * @param args - The arguments passed to the tool (validated by implementation) - * @returns A promise that resolves to the tool's execution result - */ - execute(args: unknown): Promise; -} - -/** - * Metadata for schema generation from class properties. - */ -interface PropertyMetadata { - type: "string" | "number" | "boolean" | "array" | "object"; - description: string; - required: boolean; +export class EmptyToolArgs { + static schema = {}; } /** @@ -43,28 +22,14 @@ interface PropertyMetadata { * * @template TArgs - The strongly-typed arguments class for this tool */ -export abstract class Tool implements ToolInterface { - private _definition: MCPTool | undefined; +export abstract class Tool { + private readonly logger = createLogger("Tool"); protected constructor( protected mcpServer: PenpotMcpServer, - private ArgsClass: new () => TArgs + private inputSchema: z.ZodRawShape ) {} - /** - * Gets the tool definition with automatically generated JSON schema. - */ - get definition(): MCPTool { - if (!this._definition) { - this._definition = { - name: this.getToolName(), - description: this.getToolDescription(), - inputSchema: this.generateInputSchema(), - }; - } - return this._definition; - } - /** * Executes the tool with automatic validation and type safety. * @@ -73,132 +38,35 @@ export abstract class Tool implements ToolInterface { */ async execute(args: unknown): Promise { try { - // transform plain object to class instance - const argsInstance = plainToClass(this.ArgsClass, args as object); + this.logger.info("Executing tool: %s", this.getToolName()); - // 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(", ")}`); - } + let argsInstance: TArgs = args as TArgs; + this.logger.debug("Tool args: %o", argsInstance); // execute the actual tool logic - return await this.executeCore(argsInstance); + let result = await this.executeCore(argsInstance); + + this.logger.info("Tool execution completed: %s", this.getToolName()); + return result; } catch (error) { + this.logger.error(error); return new TextResponse(`Tool execution failed: ${String(error)}`); } } - /** - * Generates JSON schema from class-validator decorators and property metadata. - */ - private generateInputSchema() { - const instance = new this.ArgsClass(); - const properties: Record = {}; - 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); - } - } - - return { - type: "object" as const, - properties, - required, - additionalProperties: false, - }; - } - - /** - * Gets all property names from a class instance. - */ - 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" - ); - } - - /** - * Extracts property metadata from class-validator decorators. - */ - private getPropertyMetadata(target: any, propertyKey: string): PropertyMetadata { - const validationMetadata = Reflect.getMetadata("class-validator:storage", target) || {}; - const constraints = validationMetadata.validationMetadatas || []; - - let isRequired = true; - let type: PropertyMetadata["type"] = "string"; - let description = `${propertyKey} parameter`; - - for (const constraint of constraints) { - if (constraint.propertyName === propertyKey) { - switch (constraint.type) { - case "isOptional": - isRequired = false; - break; - case "isString": - type = "string"; - break; - case "isNumber": - type = "number"; - break; - case "isBoolean": - type = "boolean"; - break; - case "isArray": - type = "array"; - break; - } - } - } - - // Fallback type inference - if ( - propertyKey.toLowerCase().includes("count") || - propertyKey.toLowerCase().includes("number") || - propertyKey.toLowerCase().includes("amount") - ) { - type = "number"; - } - - return { type, description, required: isRequired }; - } - - /** - * Formats validation errors into human-readable messages. - */ - private formatValidationErrors(errors: ValidationError[]): string[] { - return errors.map((error) => { - const constraints = Object.values(error.constraints || {}); - return `${error.property}: ${constraints.join(", ")}`; - }); + public getInputSchema() { + return this.inputSchema; } /** * Returns the tool's unique name. */ - protected abstract getToolName(): string; + public abstract getToolName(): string; /** * Returns the tool's description. */ - protected abstract getToolDescription(): string; + public abstract getToolDescription(): string; /** * Executes the tool's core logic. diff --git a/mcp-server/src/tools/ExecuteCodeTool.ts b/mcp-server/src/tools/ExecuteCodeTool.ts index f3f6ee7..4e34ec5 100644 --- a/mcp-server/src/tools/ExecuteCodeTool.ts +++ b/mcp-server/src/tools/ExecuteCodeTool.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from "class-validator"; +import { z } from "zod"; import { Tool } from "../Tool"; import type { ToolResponse } from "../ToolResponse"; import { TextResponse } from "../ToolResponse"; @@ -11,11 +11,13 @@ import { ExecuteCodeTaskParams } from "@penpot-mcp/common"; * Arguments class for ExecuteCodeTool */ export class ExecuteCodeArgs { + static schema = { + code: z.string().min(1, "Code cannot be empty"), + }; + /** * The JavaScript code to execute in the plugin context. */ - @IsString({ message: "Code must be a string" }) - @IsNotEmpty({ message: "Code cannot be empty" }) code!: string; } @@ -29,14 +31,14 @@ export class ExecuteCodeTool extends Tool { * @param mcpServer - The MCP server instance */ constructor(mcpServer: PenpotMcpServer) { - super(mcpServer, ExecuteCodeArgs); + super(mcpServer, ExecuteCodeArgs.schema); } - protected getToolName(): string { + public getToolName(): string { return "execute_code"; } - protected getToolDescription(): string { + public getToolDescription(): string { return ( "Executes JavaScript code in the Penpot plugin context. " + "Two objects are available: `penpot` (the Penpot API) and `storage` (an object in which arbitrary " + diff --git a/mcp-server/src/tools/HelloWorldTool.ts b/mcp-server/src/tools/HelloWorldTool.ts index 7b5aec1..e3910a8 100644 --- a/mcp-server/src/tools/HelloWorldTool.ts +++ b/mcp-server/src/tools/HelloWorldTool.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from "class-validator"; +import { z } from "zod"; import { Tool } from "../Tool"; import "reflect-metadata"; import type { ToolResponse } from "../ToolResponse"; @@ -9,11 +9,13 @@ import { PenpotMcpServer } from "../PenpotMcpServer"; * Arguments class for the HelloWorld tool with validation decorators. */ export class HelloWorldArgs { + static schema = { + name: z.string(), + }; + /** * The name to include in the greeting message. */ - @IsString({ message: "Name must be a string" }) - @IsNotEmpty({ message: "Name cannot be empty" }) name!: string; } @@ -28,14 +30,14 @@ export class HelloWorldTool extends Tool { * @param mcpServer - The MCP server instance */ constructor(mcpServer: PenpotMcpServer) { - super(mcpServer, HelloWorldArgs); + super(mcpServer, HelloWorldArgs.schema); } - protected getToolName(): string { + public getToolName(): string { return "hello_world"; } - protected getToolDescription(): string { + public getToolDescription(): string { return "Returns a personalized greeting message with the provided name"; } diff --git a/mcp-server/src/tools/PrintTextTool.ts b/mcp-server/src/tools/PrintTextTool.ts index c829430..9700266 100644 --- a/mcp-server/src/tools/PrintTextTool.ts +++ b/mcp-server/src/tools/PrintTextTool.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from "class-validator"; +import { z } from "zod"; import { Tool } from "../Tool"; import type { ToolResponse } from "../ToolResponse"; import { TextResponse } from "../ToolResponse"; @@ -11,11 +11,13 @@ import { PrintTextTaskParams } from "@penpot-mcp/common"; * Arguments class for the PrintText tool with validation decorators. */ export class PrintTextArgs { + static schema = { + text: z.string().min(1, "Text cannot be empty"), + }; + /** * The text to create in Penpot. */ - @IsString({ message: "Text must be a string" }) - @IsNotEmpty({ message: "Text cannot be empty" }) text!: string; } @@ -32,14 +34,14 @@ export class PrintTextTool extends Tool { * @param mcpServer - The MCP server instance */ constructor(mcpServer: PenpotMcpServer) { - super(mcpServer, PrintTextArgs); + super(mcpServer, PrintTextArgs.schema); } - protected getToolName(): string { + public getToolName(): string { return "print_text"; } - protected getToolDescription(): string { + public getToolDescription(): string { return "Creates text in Penpot at the viewport center and selects it"; }