diff --git a/mcp-server/src/PenpotMcpServer.ts b/mcp-server/src/PenpotMcpServer.ts index 34e0749..dc8ff42 100644 --- a/mcp-server/src/PenpotMcpServer.ts +++ b/mcp-server/src/PenpotMcpServer.ts @@ -1,23 +1,5 @@ #!/usr/bin/env node -/** - * 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 - * - Help: node dist/index.js --help - */ - import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { WebSocket, WebSocketServer } from "ws"; @@ -28,10 +10,7 @@ import { PrintTextTool } from "./tools/PrintTextTool.js"; import { PluginTask } from "./interfaces/PluginTask.js"; /** - * Main MCP server implementation for Penpot integration. - * - * This server manages tool registration and execution using a clean - * abstraction pattern that allows for easy extension with new tools. + * Penpot MCP server implementation with HTTP and SSE Transport Support */ export class PenpotMcpServer { private readonly server: Server; @@ -314,61 +293,3 @@ export class PenpotMcpServer { }); } } - -/** - * Application entry point. - * - * Creates and starts the MCP server instance, handling any startup errors - * gracefully and ensuring proper process termination. - */ -async function main(): Promise { - try { - // Parse command line arguments for port configuration - const args = process.argv.slice(2); - let port = 4401; // Default port - - for (let i = 0; i < args.length; i++) { - 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) { - port = portArg; - } else { - console.error("Invalid port number. Using default port 4401."); - } - } - } 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(" --help, -h Show this help message"); - process.exit(0); - } - } - - const server = new PenpotMcpServer(port); - await server.start(); - - // Keep the process alive - process.on("SIGINT", () => { - console.error("Received SIGINT, shutting down gracefully..."); - process.exit(0); - }); - - process.on("SIGTERM", () => { - console.error("Received SIGTERM, shutting down gracefully..."); - process.exit(0); - }); - } catch (error) { - console.error("Failed to start MCP server:", error); - process.exit(1); - } -} - -// Start the server if this file is run directly -if (import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("index.js")) { - main().catch((error) => { - console.error("Unhandled error in main:", error); - process.exit(1); - }); -} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 34e0749..0c95185 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -1,16 +1,12 @@ #!/usr/bin/env node +import { PenpotMcpServer } from "./PenpotMcpServer.js"; + /** - * Penpot MCP Server with HTTP and SSE Transport Support + * Entry point for Penpot MCP Server * - * 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 + * Creates and starts the MCP server instance, handling any startup errors + * gracefully and ensuring proper process termination. * * Usage: * - Default port: node dist/index.js (runs on 4401) @@ -18,309 +14,6 @@ * - Help: node dist/index.js --help */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { WebSocket, WebSocketServer } from "ws"; - -import { ToolInterface } from "./interfaces/Tool.js"; -import { HelloWorldTool } from "./tools/HelloWorldTool.js"; -import { PrintTextTool } from "./tools/PrintTextTool.js"; -import { PluginTask } from "./interfaces/PluginTask.js"; - -/** - * Main MCP server implementation for Penpot integration. - * - * This server manages tool registration and execution using a clean - * abstraction pattern that allows for easy extension with new tools. - */ -export class PenpotMcpServer { - private readonly server: Server; - private readonly tools: Map; - private readonly wsServer: WebSocketServer; - private readonly connectedClients: Set = new Set(); - private app: any; // Express app - private readonly port: number; - - // Store transports for each session type - private readonly transports = { - streamable: {} as Record, // StreamableHTTPServerTransport - 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) { - this.port = port; - this.server = new Server( - { - name: "penpot-mcp-server", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - this.tools = new Map(); - this.wsServer = new WebSocketServer({ port: 8080 }); - - this.setupMcpHandlers(); - this.setupWebSocketHandlers(); - 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)]; - - for (const tool of toolInstances) { - this.tools.set(tool.definition.name, 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; - }, - // 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]; - } - }; - - // 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"); - - // Create SSE transport for legacy clients - 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); - } - - /** - * 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]; - - if (transport) { - await transport.handlePostMessage(req, res, req.body); - } else { - res.status(400).send("No transport found for sessionId"); - } - } - - /** - * Sets up WebSocket connection handlers for plugin communication. - * - * Manages client connections and provides bidirectional communication - * channel between the MCP server and Penpot plugin instances. - */ - private setupWebSocketHandlers(): void { - this.wsServer.on("connection", (ws: WebSocket) => { - console.error("New WebSocket connection established"); - this.connectedClients.add(ws); - - ws.on("message", (data: Buffer) => { - console.error("Received WebSocket message:", data.toString()); - // Protocol will be defined later - }); - - ws.on("close", () => { - console.error("WebSocket connection closed"); - this.connectedClients.delete(ws); - }); - - ws.on("error", (error) => { - console.error("WebSocket connection error:", error); - this.connectedClients.delete(ws); - }); - }); - - 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. - * - * This method establishes the HTTP server and begins listening - * for both modern and legacy MCP protocol connections. - */ - 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()); - - this.setupHttpEndpoints(); - - return new Promise((resolve) => { - this.app.listen(this.port, () => { - console.error(`Penpot MCP Server started successfully on port ${this.port}`); - console.error(`Modern Streamable HTTP endpoint: http://localhost:${this.port}/mcp`); - console.error(`Legacy SSE endpoint: http://localhost:${this.port}/sse`); - console.error("WebSocket server is listening on ws://localhost:8080"); - resolve(); - }); - }); - } -} - -/** - * Application entry point. - * - * Creates and starts the MCP server instance, handling any startup errors - * gracefully and ensuring proper process termination. - */ async function main(): Promise { try { // Parse command line arguments for port configuration diff --git a/mcp-server/src/interfaces/Tool.ts b/mcp-server/src/interfaces/Tool.ts index 1b6e19a..a0cc694 100644 --- a/mcp-server/src/interfaces/Tool.ts +++ b/mcp-server/src/interfaces/Tool.ts @@ -3,7 +3,7 @@ import { validate, ValidationError } from "class-validator"; import { plainToClass } from "class-transformer"; import "reflect-metadata"; import { TextResponse, ToolResponse } from "./ToolResponse.js"; -import type { PenpotMcpServer } from "../index.js"; +import type { PenpotMcpServer } from "../PenpotMcpServer.js"; /** * Base interface for MCP tool implementations. diff --git a/mcp-server/src/tools/HelloWorldTool.ts b/mcp-server/src/tools/HelloWorldTool.ts index 2198b8f..3fb8d59 100644 --- a/mcp-server/src/tools/HelloWorldTool.ts +++ b/mcp-server/src/tools/HelloWorldTool.ts @@ -3,7 +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"; +import { PenpotMcpServer } from "../PenpotMcpServer.js"; /** * Arguments class for the HelloWorld tool with validation decorators. diff --git a/mcp-server/src/tools/PrintTextTool.ts b/mcp-server/src/tools/PrintTextTool.ts index a47c7c9..d57374b 100644 --- a/mcp-server/src/tools/PrintTextTool.ts +++ b/mcp-server/src/tools/PrintTextTool.ts @@ -4,7 +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"; +import { PenpotMcpServer } from "../PenpotMcpServer.js"; /** * Arguments class for the PrintText tool with validation decorators.