diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 9cd48e5..31b6923 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -9,7 +9,10 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^0.4.0" + "@modelcontextprotocol/sdk": "^0.4.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@types/node": "^20.0.0", @@ -98,6 +101,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/validator": { + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -136,6 +145,23 @@ "node": ">= 0.8" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -202,6 +228,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/libphonenumber-js": { + "version": "1.12.15", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.15.tgz", + "integrity": "sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==", + "license": "MIT" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -238,6 +270,12 @@ "node": ">= 0.10" } }, + "node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -340,6 +378,15 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index bd2efa7..5129517 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -19,7 +19,10 @@ "author": "", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^0.4.0" + "@modelcontextprotocol/sdk": "^0.4.0", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 8874ffc..21d9442 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -41,7 +41,9 @@ class PenpotMcpServer { * the internal registry for later execution. */ private registerTools(): void { - const toolInstances: Tool[] = [new HelloWorldTool()]; + const toolInstances: Tool[] = [ + new HelloWorldTool() + ]; for (const tool of toolInstances) { this.tools.set(tool.definition.name, tool); diff --git a/mcp-server/src/interfaces/Tool.ts b/mcp-server/src/interfaces/Tool.ts index 1ca9612..a34d54e 100644 --- a/mcp-server/src/interfaces/Tool.ts +++ b/mcp-server/src/interfaces/Tool.ts @@ -1,26 +1,211 @@ import { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js"; +import { validate, ValidationError } from "class-validator"; +import { plainToClass } from "class-transformer"; +import "reflect-metadata"; /** * Defines the contract for MCP tool implementations. * - * This interface abstracts the common operations required for all tools - * in the MCP server, providing a standardized way to register and execute - * tool functionality. + * This interface maintains compatibility with the MCP protocol while + * supporting both type-safe and traditional implementations. */ export interface Tool { /** * The tool's unique identifier and metadata definition. - * - * This property contains the tool's name, description, and input schema - * as required by the MCP protocol. */ readonly definition: MCPTool; /** * Executes the tool's primary functionality with provided arguments. * - * @param args - The arguments passed to the tool, validated against the input schema + * @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<{ content: Array<{ type: string; text: string }> }>; } + +/** + * Metadata for schema generation from class properties. + */ +interface PropertyMetadata { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description: string; + required: boolean; +} + +/** + * Base class for type-safe tools with automatic schema generation and validation. + * + * This class directly implements the Tool interface while providing type safety + * through automatic validation and strongly-typed protected methods. + * + * @template TArgs - The strongly-typed arguments class for this tool + */ +export abstract class TypeSafeTool implements Tool { + private _definition: MCPTool | undefined; + + constructor(private ArgsClass: new () => TArgs) {} + + /** + * 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. + * + * This method handles the unknown args from the MCP protocol, + * validates them, and delegates to the type-safe implementation. + */ + async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { + 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(', ')}`); + } + + // Call the type-safe implementation + return await this.executeTypeSafe(argsInstance); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`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(', ')}`; + }); + } + + /** + * Returns the tool's unique name. + */ + protected abstract getToolName(): string; + + /** + * Returns the tool's description. + */ + protected abstract getToolDescription(): string; + + /** + * Executes the tool with strongly-typed, pre-validated arguments. + * + * This method receives fully validated and typed arguments, providing + * complete type safety without any casting or manual validation. + * + * @param args - The validated, strongly-typed arguments + */ + protected abstract executeTypeSafe(args: TArgs): Promise<{ content: Array<{ type: string; text: string }> }>; +} diff --git a/mcp-server/src/tools/HelloWorldTool.ts b/mcp-server/src/tools/HelloWorldTool.ts index c823435..d248971 100644 --- a/mcp-server/src/tools/HelloWorldTool.ts +++ b/mcp-server/src/tools/HelloWorldTool.ts @@ -1,51 +1,52 @@ -import { Tool } from "../interfaces/Tool.js"; -import { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js"; +import { IsString, IsNotEmpty } from "class-validator"; +import { TypeSafeTool } from "../interfaces/Tool.js"; +import "reflect-metadata"; /** - * A simple demonstration tool that returns a personalized greeting message. - * - * This tool serves as a basic example of the Tool interface implementation - * and provides personalized "Hello, !" functionality for testing purposes. + * Arguments class for the HelloWorld tool with validation decorators. */ -export class HelloWorldTool implements Tool { +export class HelloWorldArgs { /** - * The tool definition as required by the MCP protocol. + * The name to include in the greeting message. */ - readonly definition: MCPTool = { - name: "hello_world", - description: "Returns a personalized greeting message with the provided name", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "The name to include in the greeting message", - }, - }, - required: ["name"], - additionalProperties: false, - }, - }; + @IsString({ message: "Name must be a string" }) + @IsNotEmpty({ message: "Name cannot be empty" }) + name!: string; +} + +/** + * Type-safe HelloWorld tool with automatic validation and schema generation. + * + * This tool directly implements the Tool interface while maintaining full + * type safety through the protected executeTypeSafe method. + */ +export class HelloWorldTool extends TypeSafeTool { + constructor() { + super(HelloWorldArgs); + } + + protected getToolName(): string { + return "hello_world"; + } + + protected getToolDescription(): string { + return "Returns a personalized greeting message with the provided name"; + } /** - * Executes the hello world functionality with personalized greeting. + * Executes the hello world functionality with type-safe, validated arguments. * - * @param args - The tool arguments containing the name parameter - * @returns A promise resolving to the personalized greeting message + * This method receives fully validated arguments with complete type safety. + * No casting or manual validation is required. + * + * @param args - The validated HelloWorldArgs instance */ - async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { - // Validate and extract the name from arguments - const typedArgs = args as { name?: string }; - - if (!typedArgs.name || typeof typedArgs.name !== "string") { - throw new Error("Name parameter is required and must be a string"); - } - + protected async executeTypeSafe(args: HelloWorldArgs): Promise<{ content: Array<{ type: string; text: string }> }> { return { content: [ { type: "text", - text: `Hello, ${typedArgs.name}!`, + text: `Hello, ${args.name}! This greeting was generated with full type safety and automatic validation.`, }, ], }; diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json index ed27506..21deb02 100644 --- a/mcp-server/tsconfig.json +++ b/mcp-server/tsconfig.json @@ -13,7 +13,9 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]