Fully typed tool interfaces

This commit is contained in:
Dominik Jain 2025-09-10 15:21:14 +02:00
parent 71b67349d2
commit a99851e7dd
6 changed files with 286 additions and 46 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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<TArgs extends object> 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<string, any> = {};
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 }> }>;
}

View File

@ -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, <name>!" 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<HelloWorldArgs> {
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.`,
},
],
};

View File

@ -13,7 +13,9 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]