mirror of
https://github.com/penpot/penpot-mcp.git
synced 2026-04-25 11:18:37 +00:00
Fully typed tool interfaces
This commit is contained in:
parent
71b67349d2
commit
a99851e7dd
49
mcp-server/package-lock.json
generated
49
mcp-server/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 }> }>;
|
||||
}
|
||||
|
||||
@ -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.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -13,7 +13,9 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user