Skip to main content
Glama
ContractTestFramework.ts24.2 kB
/** * Contract Testing Framework for MCP Tools * Validates MCP tool interfaces, schemas, and API contracts */ import { z } from 'zod'; import { MCPServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; export interface ToolContract { name: string; description: string; inputSchema: z.ZodSchema; outputSchema: z.ZodSchema; requiredPermissions?: string[]; sideEffects?: 'none' | 'read' | 'write' | 'system'; errorCodes?: string[]; } export interface ContractTestResult { tool: string; passed: boolean; errors: string[]; warnings: string[]; performance: { validationTimeMs: number; memoryUsageBytes: number; }; } export interface APIContractValidationResult { toolsValidated: number; contractsPassed: number; contractsFailed: number; overallSuccess: boolean; results: ContractTestResult[]; summary: { criticalIssues: string[]; warnings: string[]; recommendations: string[]; }; } export class ContractTestFramework { private static toolContracts: Map<string, ToolContract> = new Map(); private static mcpServer: MCPServer | null = null; /** * Initialize the contract testing framework */ static async initialize(server: MCPServer): Promise<void> { this.mcpServer = server; await this.loadStandardContracts(); } /** * Register a tool contract for validation */ static registerContract(contract: ToolContract): void { this.toolContracts.set(contract.name, contract); } /** * Validate all MCP tool contracts */ static async validateMCPToolContracts(): Promise<APIContractValidationResult> { if (!this.mcpServer) { throw new Error('Contract testing framework not initialized'); } const results: ContractTestResult[] = []; const criticalIssues: string[] = []; const warnings: string[] = []; const recommendations: string[] = []; // Get all available tools from the server const toolsResponse = await this.mcpServer.request( { method: 'tools/list' }, { requestId: 'contract-test' } ); const tools = toolsResponse.tools || []; for (const tool of tools) { const result = await this.validateToolContract(tool); results.push(result); if (!result.passed) { criticalIssues.push(`${tool.name}: Contract validation failed`); criticalIssues.push(...result.errors); } if (result.warnings.length > 0) { warnings.push(`${tool.name}: ${result.warnings.join(', ')}`); } } // Generate recommendations recommendations.push(...this.generateRecommendations(results)); const contractsPassed = results.filter(r => r.passed).length; const contractsFailed = results.filter(r => !r.passed).length; return { toolsValidated: results.length, contractsPassed, contractsFailed, overallSuccess: contractsFailed === 0, results, summary: { criticalIssues, warnings, recommendations } }; } /** * Validate a specific tool contract */ static async validateToolContract(tool: Tool): Promise<ContractTestResult> { const startTime = Date.now(); const startMemory = process.memoryUsage().heapUsed; const errors: string[] = []; const warnings: string[] = []; try { const contract = this.toolContracts.get(tool.name); if (!contract) { errors.push(`No contract definition found for tool: ${tool.name}`); return this.createResult(tool.name, false, errors, warnings, startTime, startMemory); } // Validate basic tool structure this.validateToolStructure(tool, contract, errors, warnings); // Validate input schema await this.validateInputSchema(tool, contract, errors, warnings); // Validate output schema (if available) await this.validateOutputSchema(tool, contract, errors, warnings); // Validate permissions and side effects this.validatePermissions(tool, contract, errors, warnings); // Performance validation this.validatePerformanceRequirements(tool, contract, errors, warnings); const passed = errors.length === 0; return this.createResult(tool.name, passed, errors, warnings, startTime, startMemory); } catch (error) { errors.push(`Contract validation failed: ${error.message}`); return this.createResult(tool.name, false, errors, warnings, startTime, startMemory); } } /** * Validate tool input/output with real data */ static async validateToolWithTestData( toolName: string, testCases: Array<{ input: any; expectedOutput?: any; shouldFail?: boolean }> ): Promise<{ totalTests: number; passed: number; failed: number; results: Array<{ testCase: number; passed: boolean; error?: string; actualOutput?: any; }>; }> { if (!this.mcpServer) { throw new Error('Contract testing framework not initialized'); } const results = []; let passed = 0; let failed = 0; for (let i = 0; i < testCases.length; i++) { const testCase = testCases[i]; try { const response = await this.mcpServer.request( { method: 'tools/call', params: { name: toolName, arguments: testCase.input } }, { requestId: `contract-test-${i}` } ); if (testCase.shouldFail) { // Expected failure but got success results.push({ testCase: i + 1, passed: false, error: 'Expected failure but tool call succeeded', actualOutput: response }); failed++; } else { // Validate output if expected output provided let outputValid = true; let outputError: string | undefined; if (testCase.expectedOutput) { outputValid = this.deepEqual(response, testCase.expectedOutput); if (!outputValid) { outputError = 'Output does not match expected result'; } } results.push({ testCase: i + 1, passed: outputValid, error: outputError, actualOutput: response }); if (outputValid) { passed++; } else { failed++; } } } catch (error) { if (testCase.shouldFail) { // Expected failure and got failure results.push({ testCase: i + 1, passed: true, actualOutput: { error: error.message } }); passed++; } else { // Unexpected failure results.push({ testCase: i + 1, passed: false, error: error.message }); failed++; } } } return { totalTests: testCases.length, passed, failed, results }; } /** * Generate comprehensive contract test report */ static async generateContractTestReport(): Promise<{ timestamp: string; summary: APIContractValidationResult; detailedResults: { [toolName: string]: { contract: ToolContract; validation: ContractTestResult; testData?: any; }; }; recommendations: string[]; nextSteps: string[]; }> { const summary = await this.validateMCPToolContracts(); const detailedResults: any = {}; const recommendations: string[] = []; const nextSteps: string[] = []; // Build detailed results for (const result of summary.results) { const contract = this.toolContracts.get(result.tool); if (contract) { detailedResults[result.tool] = { contract, validation: result }; } } // Generate recommendations if (summary.contractsFailed > 0) { recommendations.push('Fix failing contract validations before deployment'); nextSteps.push('Review and update tool implementations to match contracts'); } if (summary.summary.warnings.length > 0) { recommendations.push('Address contract warnings to improve API quality'); nextSteps.push('Update documentation and schema definitions'); } recommendations.push('Implement automated contract testing in CI/CD pipeline'); nextSteps.push('Set up contract regression monitoring'); return { timestamp: new Date().toISOString(), summary, detailedResults, recommendations, nextSteps }; } /** * Load standard MCP tool contracts */ private static async loadStandardContracts(): Promise<void> { // BMAD Tool Contracts this.registerContract({ name: 'bmad_parse_specification', description: 'Parse business specifications and generate tasks', inputSchema: z.object({ content: z.string().min(1), format: z.enum(['markdown', 'yaml', 'plain']).optional(), generateTasks: z.boolean().optional(), autoAssign: z.boolean().optional() }), outputSchema: z.object({ success: z.boolean(), tasks: z.array(z.object({ id: z.string(), title: z.string(), description: z.string(), priority: z.enum(['low', 'medium', 'high']), assignedAgent: z.string().optional() })).optional(), message: z.string() }), sideEffects: 'write', errorCodes: ['PARSE_ERROR', 'VALIDATION_ERROR'] }); this.registerContract({ name: 'bmad_update_task_status', description: 'Update task status', inputSchema: z.object({ taskId: z.string(), status: z.enum(['pending', 'in_progress', 'completed', 'blocked']), notes: z.string().optional() }), outputSchema: z.object({ success: z.boolean(), message: z.string(), updatedTask: z.object({ id: z.string(), status: z.string(), lastUpdated: z.string() }).optional() }), sideEffects: 'write', errorCodes: ['TASK_NOT_FOUND', 'INVALID_STATUS'] }); // Documentation Tool Contracts this.registerContract({ name: 'docs_reference', description: 'Find relevant documentation for development work', inputSchema: z.object({ workType: z.string(), description: z.string(), filePaths: z.array(z.string()).optional() }), outputSchema: z.object({ success: z.boolean(), documents: z.array(z.object({ path: z.string(), title: z.string(), relevance: z.number().min(0).max(1), excerpt: z.string() })), message: z.string() }), sideEffects: 'read', errorCodes: ['NO_DOCUMENTS_FOUND', 'ACCESS_ERROR'] }); this.registerContract({ name: 'docs_update', description: 'Update documentation after completing work', inputSchema: z.object({ workDescription: z.string(), updatedFiles: z.array(z.string()), documentationChanges: z.string() }), outputSchema: z.object({ success: z.boolean(), updatedDocuments: z.array(z.string()), message: z.string() }), sideEffects: 'write', errorCodes: ['UPDATE_FAILED', 'FILE_NOT_FOUND'] }); this.registerContract({ name: 'docs_search', description: 'Search through project documentation', inputSchema: z.object({ query: z.string().min(1), maxResults: z.number().min(1).max(100).optional(), documentTypes: z.array(z.string()).optional() }), outputSchema: z.object({ success: z.boolean(), results: z.array(z.object({ path: z.string(), title: z.string(), snippet: z.string(), score: z.number() })), totalResults: z.number(), message: z.string() }), sideEffects: 'read', errorCodes: ['SEARCH_ERROR', 'INVALID_QUERY'] }); this.registerContract({ name: 'docs_validate', description: 'Validate documentation structure', inputSchema: z.object({ documentPath: z.string().optional(), validationType: z.enum(['structure', 'links', 'content', 'all']).optional() }), outputSchema: z.object({ success: z.boolean(), validationResults: z.object({ errors: z.array(z.string()), warnings: z.array(z.string()), suggestions: z.array(z.string()) }), message: z.string() }), sideEffects: 'read', errorCodes: ['VALIDATION_ERROR', 'FILE_ACCESS_ERROR'] }); // Hooks Tool Contracts this.registerContract({ name: 'hooks_trigger', description: 'Manually trigger hook events', inputSchema: z.object({ eventType: z.string(), data: z.record(z.any()), target: z.string().optional() }), outputSchema: z.object({ success: z.boolean(), triggeredHooks: z.array(z.string()), results: z.array(z.object({ hook: z.string(), success: z.boolean(), output: z.string() })), message: z.string() }), sideEffects: 'system', errorCodes: ['HOOK_NOT_FOUND', 'EXECUTION_ERROR'] }); this.registerContract({ name: 'hooks_setup_git', description: 'Setup Git hooks', inputSchema: z.object({ projectRoot: z.string(), hooks: z.array(z.enum(['pre-commit', 'post-commit', 'pre-push'])), overwrite: z.boolean().optional() }), outputSchema: z.object({ success: z.boolean(), installedHooks: z.array(z.string()), message: z.string() }), sideEffects: 'write', requiredPermissions: ['file_system_write'], errorCodes: ['GIT_NOT_FOUND', 'PERMISSION_ERROR'] }); // Enhanced Tool Contracts this.registerContract({ name: 'initialize_documentation_system', description: 'Initialize the enhanced documentation system', inputSchema: z.object({ projectRoot: z.string(), enableAI: z.boolean().optional(), timeZone: z.string().optional(), locale: z.string().optional() }), outputSchema: z.object({ success: z.boolean(), systemStatus: z.object({ database: z.string(), services: z.array(z.string()), aiEnabled: z.boolean() }), message: z.string() }), sideEffects: 'write', requiredPermissions: ['file_system_write', 'database_access'], errorCodes: ['INITIALIZATION_ERROR', 'DATABASE_ERROR'] }); this.registerContract({ name: 'track_document_work', description: 'Track work-document relationships', inputSchema: z.object({ workType: z.string(), workDescription: z.string(), filePaths: z.array(z.string()), expectedDocuments: z.array(z.string()).optional() }), outputSchema: z.object({ success: z.boolean(), connections: z.array(z.object({ documentId: z.string(), connectionStrength: z.number(), relevanceScore: z.number() })), recommendations: z.array(z.string()), message: z.string() }), sideEffects: 'write', errorCodes: ['TRACKING_ERROR', 'FILE_ACCESS_ERROR'] }); } /** * Validate tool structure */ private static validateToolStructure( tool: Tool, contract: ToolContract, errors: string[], warnings: string[] ): void { if (tool.name !== contract.name) { errors.push(`Tool name mismatch: expected ${contract.name}, got ${tool.name}`); } if (!tool.description || tool.description.trim().length === 0) { errors.push(`Tool ${tool.name} missing description`); } else if (tool.description !== contract.description) { warnings.push(`Tool description differs from contract`); } if (!tool.inputSchema) { errors.push(`Tool ${tool.name} missing input schema`); } } /** * Validate input schema */ private static async validateInputSchema( tool: Tool, contract: ToolContract, errors: string[], warnings: string[] ): Promise<void> { if (!tool.inputSchema) { return; } try { // Basic schema structure validation const schema = tool.inputSchema; if (typeof schema !== 'object') { errors.push(`Invalid input schema format for ${tool.name}`); return; } if (!schema.type) { warnings.push(`Input schema missing type property for ${tool.name}`); } if (schema.type === 'object' && !schema.properties) { warnings.push(`Object schema missing properties for ${tool.name}`); } // Validate required fields exist if (schema.required && Array.isArray(schema.required)) { if (!schema.properties) { errors.push(`Required fields specified but no properties defined for ${tool.name}`); } else { for (const requiredField of schema.required) { if (!(requiredField in schema.properties)) { errors.push(`Required field '${requiredField}' not found in properties for ${tool.name}`); } } } } } catch (error) { errors.push(`Input schema validation error for ${tool.name}: ${error.message}`); } } /** * Validate output schema */ private static async validateOutputSchema( tool: Tool, contract: ToolContract, errors: string[], warnings: string[] ): Promise<void> { // Output schema validation would require actual tool execution // For now, we check if the contract defines expected output structure if (!contract.outputSchema) { warnings.push(`No output schema defined in contract for ${tool.name}`); } } /** * Validate permissions and side effects */ private static validatePermissions( tool: Tool, contract: ToolContract, errors: string[], warnings: string[] ): void { if (contract.sideEffects === 'write' || contract.sideEffects === 'system') { if (!contract.requiredPermissions) { warnings.push(`Tool ${tool.name} has side effects but no required permissions specified`); } } if (contract.requiredPermissions && contract.requiredPermissions.length > 0) { // In a real implementation, you would check against actual tool permissions warnings.push(`Tool ${tool.name} requires permissions: ${contract.requiredPermissions.join(', ')}`); } } /** * Validate performance requirements */ private static validatePerformanceRequirements( tool: Tool, contract: ToolContract, errors: string[], warnings: string[] ): void { // Performance validation would require actual execution timing // For now, we can check for obvious performance anti-patterns in schema const schema = tool.inputSchema; if (schema && schema.properties) { for (const [propName, propSchema] of Object.entries(schema.properties)) { if (typeof propSchema === 'object' && propSchema.type === 'array') { if (!propSchema.maxItems) { warnings.push(`Array property '${propName}' in ${tool.name} has no maximum length limit`); } } if (typeof propSchema === 'object' && propSchema.type === 'string') { if (!propSchema.maxLength) { warnings.push(`String property '${propName}' in ${tool.name} has no maximum length limit`); } } } } } /** * Create contract test result */ private static createResult( tool: string, passed: boolean, errors: string[], warnings: string[], startTime: number, startMemory: number ): ContractTestResult { const endTime = Date.now(); const endMemory = process.memoryUsage().heapUsed; return { tool, passed, errors: [...errors], warnings: [...warnings], performance: { validationTimeMs: endTime - startTime, memoryUsageBytes: endMemory - startMemory } }; } /** * Generate recommendations based on test results */ private static generateRecommendations(results: ContractTestResult[]): string[] { const recommendations: string[] = []; const failedTools = results.filter(r => !r.passed); const slowTools = results.filter(r => r.performance.validationTimeMs > 100); const memoryIntensiveTools = results.filter(r => r.performance.memoryUsageBytes > 1024 * 1024); if (failedTools.length > 0) { recommendations.push(`Fix contract validation for ${failedTools.length} tools: ${failedTools.map(t => t.tool).join(', ')}`); } if (slowTools.length > 0) { recommendations.push(`Optimize validation performance for ${slowTools.length} tools`); } if (memoryIntensiveTools.length > 0) { recommendations.push(`Review memory usage for ${memoryIntensiveTools.length} tools`); } recommendations.push('Implement contract testing in CI/CD pipeline'); recommendations.push('Set up automated contract regression monitoring'); recommendations.push('Document API versioning strategy'); return recommendations; } /** * Deep equality check for test validation */ private static deepEqual(obj1: any, obj2: any): boolean { if (obj1 === obj2) return true; if (obj1 == null || obj2 == null) return obj1 === obj2; if (typeof obj1 !== typeof obj2) return false; if (typeof obj1 !== 'object') return obj1 === obj2; const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; for (const key of keys1) { if (!keys2.includes(key)) return false; if (!this.deepEqual(obj1[key], obj2[key])) return false; } return true; } } /** * Jest setup helper for contract testing */ export const setupContractTesting = () => { let framework: typeof ContractTestFramework; beforeAll(async () => { framework = ContractTestFramework; // Server would be initialized here in real implementation }); return () => framework; }; /** * Contract test utilities */ export const contractTestUtils = { /** * Create test cases for common scenarios */ createStandardTestCases: (toolName: string): Array<{ input: any; expectedOutput?: any; shouldFail?: boolean }> => { const baseTestCases = [ // Valid input test { input: { validField: 'test' }, shouldFail: false }, // Missing required field test { input: {}, shouldFail: true }, // Invalid field type test { input: { validField: 123 }, shouldFail: true }, // Extra fields test (should be handled gracefully) { input: { validField: 'test', extraField: 'extra' }, shouldFail: false } ]; return baseTestCases; }, /** * Generate performance test cases */ createPerformanceTestCases: (toolName: string) => ({ smallInput: { data: 'small' }, mediumInput: { data: 'a'.repeat(1000) }, largeInput: { data: 'a'.repeat(10000) } }), /** * Validate contract completeness */ validateContractCompleteness: (contract: ToolContract): string[] => { const issues: string[] = []; if (!contract.description || contract.description.length < 10) { issues.push('Description too short or missing'); } if (!contract.inputSchema) { issues.push('Input schema missing'); } if (!contract.outputSchema) { issues.push('Output schema missing'); } if (contract.sideEffects === 'write' && !contract.requiredPermissions) { issues.push('Write operations should specify required permissions'); } if (!contract.errorCodes || contract.errorCodes.length === 0) { issues.push('Error codes not specified'); } return issues; } };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ghostseller/CastPlan_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server