diff --git a/mcp-server/data/prompts.yml b/mcp-server/data/prompts.yml new file mode 100644 index 0000000..e5d4826 --- /dev/null +++ b/mcp-server/data/prompts.yml @@ -0,0 +1,24 @@ +# Prompts configuration for Penpot MCP Server +# This file contains various prompts and instructions that can be used by the server + +initial_instructions: | + You have access to Penpot tools in order to interact with Penpot design projects directly. + As a precondition, the user must connect a Penpot project to the MCP server using the Penpot MCP Plugin. + + A Penpot project contains shapes and more general design elements (which we shall collectively refer to as "elements"). + + One of the key tools is the execute_code tool, which allows you to run JavaScript code using the Penpot Plugin API + directly in the connected project. + When writing code, a key object is the `penpot` object which provides key functionality: + * `penpot.selection` provides the list of elements the user has selected in the Penpot UI. + If it is unclear which elements to work on, you can ask the user to select them for you. + * Generation of CSS content for elements: + generateStyle(shapes: Shape[], options?: { + type?: "css"; + withPrelude?: boolean; + includeChildren?: boolean; + }): string; + * Generation of HTML/SVG content corresponding to elements: + generateMarkup(shapes: Shape[], options?: { + type?: "html" | "svg"; + }): string; diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index dea5a97..99802e4 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -14,6 +14,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "express": "^4.18.0", + "js-yaml": "^4.1.0", "pino": "^9.10.0", "pino-pretty": "^13.1.1", "reflect-metadata": "^0.1.13", @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/express": "^4.17.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/ws": "^8.5.10", "esbuild": "^0.19.0", @@ -843,6 +845,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -971,6 +980,12 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1636,6 +1651,18 @@ "node": ">=10" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index 5ae8b82..2d3d9e9 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty", + "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml", "build:types": "tsc --emitDeclarationOnly --outDir dist", "build:full": "npm run build && npm run build:types", "start": "node dist/index.js", @@ -26,6 +26,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "express": "^4.18.0", + "js-yaml": "^4.1.0", "pino": "^9.10.0", "pino-pretty": "^13.1.1", "reflect-metadata": "^0.1.13", @@ -33,6 +34,7 @@ }, "devDependencies": { "@types/express": "^4.17.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/ws": "^8.5.10", "esbuild": "^0.19.0", diff --git a/mcp-server/src/ConfigurationLoader.ts b/mcp-server/src/ConfigurationLoader.ts new file mode 100644 index 0000000..500b873 --- /dev/null +++ b/mcp-server/src/ConfigurationLoader.ts @@ -0,0 +1,94 @@ +import { readFileSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import yaml from "js-yaml"; +import { createLogger } from "./logger.js"; + +/** + * Interface defining the structure of the prompts configuration file. + */ +export interface PromptsConfig { + /** Initial instructions displayed when the server starts or connects to a client */ + initial_instructions?: string; + [key: string]: any; // Allow for future extension with additional prompt types +} + +/** + * Configuration loader for prompts and server settings. + * + * Handles loading and parsing of YAML configuration files, + * providing type-safe access to configuration values with + * appropriate fallbacks for missing files or values. + */ +export class ConfigurationLoader { + private readonly logger = createLogger("ConfigurationLoader"); + private readonly baseDir: string; + private promptsConfig: PromptsConfig | null = null; + + /** + * Creates a new configuration loader instance. + * + * @param baseDir - Base directory for resolving configuration file paths + */ + constructor(baseDir?: string) { + // Default to the directory containing this module + this.baseDir = baseDir || dirname(fileURLToPath(import.meta.url)); + } + + /** + * Loads the prompts configuration from the YAML file. + * + * Reads and parses the prompts.yml file, providing cached access + * to configuration values on subsequent calls. + * + * @returns The parsed prompts configuration object + */ + public getPromptsConfig(): PromptsConfig { + if (this.promptsConfig !== null) { + return this.promptsConfig; + } + + const promptsPath = join(this.baseDir, "..", "data", "prompts.yml"); + + if (!existsSync(promptsPath)) { + this.logger.warn(`Prompts configuration file not found at ${promptsPath}, using defaults`); + this.promptsConfig = {}; + return this.promptsConfig; + } + + try { + const fileContent = readFileSync(promptsPath, "utf8"); + const parsedConfig = yaml.load(fileContent) as PromptsConfig; + + this.promptsConfig = parsedConfig || {}; + this.logger.info(`Loaded prompts configuration from ${promptsPath}`); + + return this.promptsConfig; + } catch (error) { + this.logger.error(error, `Failed to load prompts configuration from ${promptsPath}`); + this.promptsConfig = {}; + return this.promptsConfig; + } + } + + /** + * Gets the initial instructions for the MCP server. + * + * @returns The initial instructions string, or undefined if not configured + */ + public getInitialInstructions(): string | undefined { + const config = this.getPromptsConfig(); + return config.initial_instructions; + } + + /** + * Reloads the configuration from disk. + * + * Forces a fresh read of the configuration file on the next access, + * useful for development or when configuration files are updated at runtime. + */ + public reloadConfiguration(): void { + this.promptsConfig = null; + this.logger.info("Configuration cache cleared, will reload on next access"); + } +} diff --git a/mcp-server/src/PenpotMcpServer.ts b/mcp-server/src/PenpotMcpServer.ts index 3f0cb8a..f57ba25 100644 --- a/mcp-server/src/PenpotMcpServer.ts +++ b/mcp-server/src/PenpotMcpServer.ts @@ -6,6 +6,7 @@ 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"; /** @@ -15,6 +16,7 @@ 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 port: number; public readonly pluginBridge: PluginBridge; @@ -46,6 +48,7 @@ export class PenpotMcpServer { ); this.tools = new Map(); + this.configLoader = new ConfigurationLoader(); this.pluginBridge = new PluginBridge(webSocketPort); this.setupMcpHandlers(); @@ -220,6 +223,40 @@ export class PenpotMcpServer { * 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.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"); @@ -234,6 +271,10 @@ export class PenpotMcpServer { 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(); + resolve(); }); });