Skip to main content
Glama

hypertool-mcp

validator.test.tsโ€ข31 kB
/** * PersonaValidator Test Suite * * Comprehensive tests for multi-layer persona validation system, including * schema validation, business rules, tool resolution, MCP config validation, * and integration with discovery engines. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { PersonaValidator, createPersonaValidator, validatePersona, validateMultiplePersonas, type ValidationContext, type ValidationOptions, } from "./validator.js"; import type { PersonaConfig, ValidationResult } from "./types.js"; import type { IToolDiscoveryEngine, AvailableToolInfo, } from "../discovery/types.js"; // Mock tool discovery engine for testing const createMockToolDiscoveryEngine = ( availableTools: AvailableToolInfo[] = [] ): IToolDiscoveryEngine => ({ getAvailableTools: vi .fn() .mockImplementation((connectedOnly: boolean = true) => connectedOnly ? availableTools.filter((tool) => tool.isConnected) : availableTools ), discoverTools: vi.fn().mockResolvedValue(availableTools), isToolAvailable: vi .fn() .mockImplementation((toolId: string) => availableTools.some((tool) => tool.namespacedName === toolId) ), clearCache: vi.fn(), getStats: vi.fn().mockReturnValue({}), }); describe("PersonaValidator", () => { let tempDir: string; let validator: PersonaValidator; let mockToolEngine: IToolDiscoveryEngine; beforeEach(async () => { // Create temporary directory for test files tempDir = await fs.mkdtemp(join(tmpdir(), "persona-validator-test-")); // Create mock tool discovery engine with some tools const mockTools: AvailableToolInfo[] = [ { namespacedName: "git.status", toolName: "status", serverName: "git", description: "Get git status", schema: {}, sourceType: "mcp-server", isConnected: true, lastSeen: new Date(), }, { namespacedName: "git.add", toolName: "add", serverName: "git", description: "Add files to git", schema: {}, sourceType: "mcp-server", isConnected: true, lastSeen: new Date(), }, { namespacedName: "docker.ps", toolName: "ps", serverName: "docker", description: "List docker containers", schema: {}, sourceType: "mcp-server", isConnected: true, lastSeen: new Date(), }, { namespacedName: "offline.tool", toolName: "tool", serverName: "offline", description: "Tool from disconnected server", schema: {}, sourceType: "mcp-server", isConnected: false, lastSeen: new Date(), }, ]; mockToolEngine = createMockToolDiscoveryEngine(mockTools); validator = new PersonaValidator(mockToolEngine); }); afterEach(async () => { // Clean up temporary files try { await fs.rmdir(tempDir, { recursive: true }); } catch { // Ignore cleanup errors } }); describe("PersonaValidator Class", () => { describe("validatePersonaConfig", () => { it("should validate a valid persona configuration", async () => { const config: PersonaConfig = { name: "test-persona", description: "A valid test persona configuration", toolsets: [ { name: "development", toolIds: ["git.status", "git.add"], }, ], defaultToolset: "development", }; const context: ValidationContext = { personaPath: "/test/path", expectedPersonaName: "test-persona", }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); expect(result.warnings).toHaveLength(0); }); it("should detect name mismatch business rule violation", async () => { const config: PersonaConfig = { name: "wrong-name", description: "A persona with wrong name", }; const context: ValidationContext = { personaPath: "/test/correct-name", expectedPersonaName: "correct-name", }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe("business"); expect(result.errors[0].field).toBe("name"); expect(result.errors[0].message).toContain( "does not match folder name" ); }); it("should detect invalid default toolset", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona with invalid default toolset", toolsets: [ { name: "development", toolIds: ["git.status"], }, ], defaultToolset: "non-existent", }; const context: ValidationContext = { personaPath: "/test/path", }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe("business"); expect(result.errors[0].field).toBe("defaultToolset"); expect(result.errors[0].message).toContain( "not defined in the toolsets array" ); }); it("should warn about similar toolset names", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona with similar toolset names", toolsets: [ { name: "dev", toolIds: ["git.status"], }, { name: "devel", // Similar to "dev" toolIds: ["git.add"], }, ], }; const context: ValidationContext = { personaPath: "/test/path", }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].type).toBe("business"); expect(result.warnings[0].message).toContain( "very similar and may be confusing" ); }); it("should warn when no toolsets are defined", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona without toolsets", }; const context: ValidationContext = { personaPath: "/test/path", }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].type).toBe("business"); expect(result.warnings[0].message).toContain("No toolsets defined"); }); it("should validate tool resolution", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona with tool resolution issues", toolsets: [ { name: "development", toolIds: ["git.status", "non.existent", "offline.tool"], }, ], }; const context: ValidationContext = { personaPath: "/test/path", toolDiscoveryEngine: mockToolEngine, }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe("tool-resolution"); expect(result.errors[0].message).toContain("non.existent"); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].type).toBe("tool-resolution"); expect(result.warnings[0].message).toContain("offline.tool"); }); it("should skip tool resolution when no discovery engine provided", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona without tool validation", toolsets: [ { name: "development", toolIds: ["non.existent"], }, ], }; const context: ValidationContext = { personaPath: "/test/path", }; // Create validator without tool discovery engine const validatorNoTool = new PersonaValidator(); const result = await validatorNoTool.validatePersonaConfig( config, context ); // Should be valid since tool resolution is skipped expect(result.isValid).toBe(true); }); it("should handle custom validators", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona for custom validation testing", }; const context: ValidationContext = { personaPath: "/test/path", }; const customValidator = (config: PersonaConfig): ValidationResult => ({ isValid: false, errors: [ { type: "business", message: "Custom validation failed", severity: "error", }, ], warnings: [ { type: "business", message: "Custom warning", severity: "warning", }, ], }); const options: ValidationOptions = { customValidators: [customValidator], }; const result = await validator.validatePersonaConfig( config, context, options ); expect(result.isValid).toBe(false); expect( result.errors.some((e) => e.message.includes("Custom validation failed") ) ).toBe(true); expect( result.warnings.some((w) => w.message.includes("Custom warning")) ).toBe(true); }); it("should handle custom validator exceptions", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona for exception testing", }; const context: ValidationContext = { personaPath: "/test/path", }; const throwingValidator = (): ValidationResult => { throw new Error("Custom validator error"); }; const options: ValidationOptions = { customValidators: [throwingValidator], }; const result = await validator.validatePersonaConfig( config, context, options ); expect(result.isValid).toBe(false); expect( result.errors.some((e) => e.message.includes("Custom validation failed") ) ).toBe(true); }); it("should stop on first error when requested", async () => { const config: PersonaConfig = { name: "wrong-name", description: "A persona with multiple issues", toolsets: [ { name: "development", toolIds: ["non.existent"], }, ], defaultToolset: "non-existent-toolset", }; const context: ValidationContext = { personaPath: "/test/correct-name", expectedPersonaName: "correct-name", }; const options: ValidationOptions = { stopOnFirstError: true, }; const result = await validator.validatePersonaConfig( config, context, options ); expect(result.isValid).toBe(false); // Should only have business rule errors, not tool resolution errors expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.every((e) => e.type === "business")).toBe(true); }); }); describe("validatePersonaFile", () => { it("should validate a valid persona file", async () => { const personaPath = join(tempDir, "valid-persona.yaml"); const yamlContent = ` name: valid-persona description: A valid persona configuration for file testing toolsets: - name: development toolIds: ["git.status", "git.add"] defaultToolset: development `; await fs.writeFile(personaPath, yamlContent); const result = await validator.validatePersonaFile(personaPath); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); it("should handle invalid YAML syntax", async () => { const personaPath = join(tempDir, "invalid.yaml"); const yamlContent = "invalid yaml: [unclosed bracket"; await fs.writeFile(personaPath, yamlContent); const result = await validator.validatePersonaFile(personaPath); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0].type).toBe("schema"); }); it("should handle non-existent files", async () => { const personaPath = join(tempDir, "non-existent.yaml"); const result = await validator.validatePersonaFile(personaPath); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toContain("Failed to validate file"); }); it("should find and validate MCP config", async () => { const personaDir = join(tempDir, "persona-with-mcp"); await fs.mkdir(personaDir); const personaPath = join(personaDir, "persona.yaml"); const yamlContent = ` name: persona-with-mcp description: A persona with MCP configuration `; await fs.writeFile(personaPath, yamlContent); const mcpPath = join(personaDir, "mcp.json"); const mcpContent = `{ "mcpServers": { "git": { "type": "stdio", "command": "git-mcp" } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await validator.validatePersonaFile(personaPath); expect(result.isValid).toBe(true); // Should not have MCP config errors expect( result.errors.filter((e) => e.type === "mcp-config") ).toHaveLength(0); }); it("should handle invalid MCP config", async () => { const personaDir = join(tempDir, "persona-bad-mcp"); await fs.mkdir(personaDir); const personaPath = join(personaDir, "persona.yaml"); const yamlContent = ` name: persona-bad-mcp description: A persona with bad MCP configuration `; await fs.writeFile(personaPath, yamlContent); const mcpPath = join(personaDir, "mcp.json"); const mcpContent = "invalid json"; await fs.writeFile(mcpPath, mcpContent); const result = await validator.validatePersonaFile(personaPath); expect(result.isValid).toBe(false); expect(result.errors.some((e) => e.type === "mcp-config")).toBe(true); }); }); describe("validatePersonaDirectory", () => { it("should validate a persona directory", async () => { const personaDir = join(tempDir, "valid-directory-persona"); await fs.mkdir(personaDir); const personaPath = join(personaDir, "persona.yaml"); const yamlContent = ` name: valid-directory-persona description: A valid persona in directory form `; await fs.writeFile(personaPath, yamlContent); const result = await validator.validatePersonaDirectory(personaDir); expect(result.isValid).toBe(true); }); it("should handle directory without persona config", async () => { const personaDir = join(tempDir, "no-config-dir"); await fs.mkdir(personaDir); await fs.writeFile( join(personaDir, "other.txt"), "not a persona config" ); const result = await validator.validatePersonaDirectory(personaDir); expect(result.isValid).toBe(false); expect(result.errors[0].message).toContain( "No persona configuration file found" ); }); it("should handle non-existent directory", async () => { const personaDir = join(tempDir, "non-existent"); const result = await validator.validatePersonaDirectory(personaDir); expect(result.isValid).toBe(false); expect(result.errors[0].message).toContain( "Failed to validate directory" ); }); }); describe("MCP Config Validation", () => { it("should validate valid MCP configuration", async () => { const mcpPath = join(tempDir, "valid-mcp.json"); const mcpContent = `{ "mcpServers": { "git": { "type": "stdio", "command": "git-mcp", "args": ["--stdio"] }, "docker": { "type": "http", "url": "http://localhost:8080" }, "extension": { "type": "dxt-extension", "path": "./extension.dxt" } } }`; await fs.writeFile(mcpPath, mcpContent); // Access private method for testing const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); it("should detect invalid JSON", async () => { const mcpPath = join(tempDir, "invalid-mcp.json"); const mcpContent = "invalid json {"; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(false); expect(result.errors[0].type).toBe("mcp-config"); expect(result.errors[0].message).toContain("Invalid JSON"); }); it("should detect missing mcpServers", async () => { const mcpPath = join(tempDir, "no-servers-mcp.json"); const mcpContent = '{"other": "field"}'; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(false); expect(result.errors[0].field).toBe("mcpServers"); }); it("should validate transport types", async () => { const mcpPath = join(tempDir, "bad-transport-mcp.json"); const mcpContent = `{ "mcpServers": { "bad": { "type": "invalid-transport", "command": "test" } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(false); expect(result.errors[0].message).toContain("Invalid transport type"); }); it("should validate required fields for stdio transport", async () => { const mcpPath = join(tempDir, "stdio-no-command.json"); const mcpContent = `{ "mcpServers": { "git": { "type": "stdio" } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(false); expect(result.errors[0].field).toContain("command"); }); it("should validate required fields for http transport", async () => { const mcpPath = join(tempDir, "http-no-url.json"); const mcpContent = `{ "mcpServers": { "api": { "type": "http" } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(false); expect(result.errors[0].field).toContain("url"); }); it("should warn about common server names", async () => { const mcpPath = join(tempDir, "common-names-mcp.json"); const mcpContent = `{ "mcpServers": { "git": { "type": "stdio", "command": "git-mcp" }, "filesystem": { "type": "stdio", "command": "fs-mcp" } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await (validator as any).validateMcpConfig(mcpPath); expect(result.isValid).toBe(true); expect(result.warnings.length).toBeGreaterThanOrEqual(1); expect( result.warnings.some((w) => w.message.includes("commonly used")) ).toBe(true); }); }); describe("Utility Methods", () => { it("should set tool discovery engine", () => { const newValidator = new PersonaValidator(); const mockEngine = createMockToolDiscoveryEngine(); newValidator.setToolDiscoveryEngine(mockEngine); const stats = newValidator.getValidationStats(); expect(stats.hasToolDiscoveryEngine).toBe(true); }); it("should provide validation statistics", () => { const stats = validator.getValidationStats(); expect(stats.hasToolDiscoveryEngine).toBe(true); expect(stats.supportedFileTypes).toContain("persona.yaml"); expect(stats.supportedFileTypes).toContain("persona.yml"); expect(stats.validationLayers).toContain("YAML Syntax & Schema"); expect(stats.validationLayers).toContain("Business Rules"); expect(stats.validationLayers).toContain("Tool Resolution"); expect(stats.validationLayers).toContain("MCP Configuration"); }); it("should detect similar names correctly", () => { // Test private method const areNamesSimilar = (validator as any).areNamesSimilar; expect(areNamesSimilar("dev", "devel")).toBe(true); expect(areNamesSimilar("test", "testing")).toBe(false); expect(areNamesSimilar("api-v1", "api-v2")).toBe(true); expect(areNamesSimilar("frontend", "backend")).toBe(false); }); }); describe("Options Handling", () => { it("should respect includeWarnings option", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona without toolsets", }; const context: ValidationContext = { personaPath: "/test/path", }; // With warnings const resultWithWarnings = await validator.validatePersonaConfig( config, context, { includeWarnings: true, } ); // Without warnings const resultWithoutWarnings = await validator.validatePersonaConfig( config, context, { includeWarnings: false, } ); expect(resultWithWarnings.warnings.length).toBeGreaterThan(0); expect(resultWithoutWarnings.warnings).toHaveLength(0); }); it("should respect checkToolAvailability option", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona with unavailable tools", toolsets: [ { name: "development", toolIds: ["non.existent"], }, ], }; const context: ValidationContext = { personaPath: "/test/path", }; // With tool checking const resultWithCheck = await validator.validatePersonaConfig( config, context, { checkToolAvailability: true, } ); // Without tool checking const resultWithoutCheck = await validator.validatePersonaConfig( config, context, { checkToolAvailability: false, } ); expect( resultWithCheck.errors.some((e) => e.type === "tool-resolution") ).toBe(true); expect( resultWithoutCheck.errors.some((e) => e.type === "tool-resolution") ).toBe(false); }); }); }); describe("Factory Functions", () => { describe("createPersonaValidator", () => { it("should create validator without tool discovery engine", () => { const createdValidator = createPersonaValidator(); const stats = createdValidator.getValidationStats(); expect(stats.hasToolDiscoveryEngine).toBe(false); }); it("should create validator with tool discovery engine", () => { const mockEngine = createMockToolDiscoveryEngine(); const createdValidator = createPersonaValidator(mockEngine); const stats = createdValidator.getValidationStats(); expect(stats.hasToolDiscoveryEngine).toBe(true); }); }); describe("validatePersona", () => { it("should validate persona file", async () => { const personaPath = join(tempDir, "quick-test.yaml"); const yamlContent = ` name: quick-test description: A persona for quick validation testing `; await fs.writeFile(personaPath, yamlContent); const result = await validatePersona(personaPath); expect(result.isValid).toBe(true); }); it("should validate persona directory", async () => { const personaDir = join(tempDir, "quick-dir-test"); await fs.mkdir(personaDir); const personaPath = join(personaDir, "persona.yaml"); const yamlContent = ` name: quick-dir-test description: A persona directory for quick validation testing `; await fs.writeFile(personaPath, yamlContent); const result = await validatePersona(personaDir); expect(result.isValid).toBe(true); }); }); describe("validateMultiplePersonas", () => { it("should validate multiple personas", async () => { // Create multiple test personas const personas = [ { name: "persona1", valid: true }, { name: "persona2", valid: false }, { name: "persona3", valid: true }, ]; const paths: string[] = []; for (const persona of personas) { const personaPath = join(tempDir, `${persona.name}.yaml`); const yamlContent = persona.valid ? `name: ${persona.name}\ndescription: Valid persona ${persona.name}` : `name: ${persona.name}\ndescription: Short`; // Too short description await fs.writeFile(personaPath, yamlContent); paths.push(personaPath); } const results = await validateMultiplePersonas(paths); expect(results.size).toBe(3); expect(results.get(paths[0])?.isValid).toBe(true); expect(results.get(paths[1])?.isValid).toBe(false); expect(results.get(paths[2])?.isValid).toBe(true); }); it("should handle validation failures gracefully", async () => { const paths = ["/non/existent/path1.yaml", "/non/existent/path2.yaml"]; const results = await validateMultiplePersonas(paths); expect(results.size).toBe(2); expect(results.get(paths[0])?.isValid).toBe(false); expect(results.get(paths[1])?.isValid).toBe(false); }); }); }); describe("Error Handling", () => { it("should handle tool discovery engine failures", async () => { const faultyEngine = { ...mockToolEngine, getAvailableTools: vi.fn().mockImplementation(() => { throw new Error("Discovery engine failure"); }), }; const config: PersonaConfig = { name: "test-persona", description: "A persona for testing engine failures", toolsets: [ { name: "development", toolIds: ["git.status"], }, ], }; const context: ValidationContext = { personaPath: "/test/path", toolDiscoveryEngine: faultyEngine, }; const result = await validator.validatePersonaConfig(config, context); expect(result.isValid).toBe(false); expect( result.errors.some( (e) => e.type === "tool-resolution" && e.message.includes("Failed to validate tool availability") ) ).toBe(true); }); it("should handle MCP config file reading failures", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona for testing MCP failures", }; const context: ValidationContext = { personaPath: "/test/path", assets: { configFile: "/test/path/persona.yaml", mcpConfigFile: "/non/existent/mcp.json", }, }; const result = await validator.validatePersonaConfig(config, context); // Should be treated as warning, not error expect(result.isValid).toBe(true); expect(result.warnings.some((w) => w.type === "mcp-config")).toBe(true); }); it("should handle general validation exceptions", async () => { const config: PersonaConfig = { name: "test-persona", description: "A persona for testing exceptions", }; // Mock context that might cause issues const context: ValidationContext = { personaPath: "/test/path", }; // Mock validator methods to throw const throwingValidator = new PersonaValidator(); (throwingValidator as any).validateBusinessRules = vi .fn() .mockImplementation(() => { throw new Error("Validation exception"); }); const result = await throwingValidator.validatePersonaConfig( config, context ); expect(result.isValid).toBe(false); expect( result.errors.some((e) => e.message.includes("Validation failed")) ).toBe(true); }); }); describe("Integration Tests", () => { it("should perform end-to-end validation", async () => { // Create a complete persona setup const personaDir = join(tempDir, "complete-persona"); await fs.mkdir(personaDir); const personaPath = join(personaDir, "persona.yaml"); const yamlContent = ` name: complete-persona description: A complete persona for end-to-end testing toolsets: - name: development toolIds: ["git.status", "git.add", "docker.ps"] - name: testing toolIds: ["git.status"] defaultToolset: development metadata: author: Test Suite tags: ["integration", "testing"] `; await fs.writeFile(personaPath, yamlContent); const mcpPath = join(personaDir, "mcp.json"); const mcpContent = `{ "mcpServers": { "git": { "type": "stdio", "command": "git-mcp", "args": ["--stdio"] }, "docker": { "type": "stdio", "command": "docker-mcp", "args": ["--stdio"] } } }`; await fs.writeFile(mcpPath, mcpContent); const result = await validator.validatePersonaDirectory(personaDir); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); // May have warnings about common server names }); }); });

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/toolprint/hypertool-mcp'

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