diff --git a/src/api/zodClient.ts b/src/api/zodClient.ts index 1eb3170af..0465bbf89 100644 --- a/src/api/zodClient.ts +++ b/src/api/zodClient.ts @@ -1,6 +1,24 @@ import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core' import { z } from 'zod' +/** + * IMPORTANT: MCP Schema Compatibility + * + * The MCP (Model Context Protocol) requires that all array types in JSON schemas + * have an 'items' property. When using Zod schemas that will be converted to JSON + * schemas for MCP tools: + * + * ❌ NEVER use: z.array(z.any()) - This doesn't generate the required 'items' property + * ✅ ALWAYS use: z.array(z.unknown()) - This generates proper JSON schemas + * + * Similarly: + * ❌ NEVER use: z.record(z.any()) + * ✅ ALWAYS use: z.record(z.unknown()) + * + * The z.unknown() type provides the same runtime flexibility as z.any() but + * generates valid JSON schemas that pass MCP validation. + */ + const EdgeDBSettings = z.object({ enabled: z.boolean() }) const ColorSettings = z.object({ primary: z @@ -436,7 +454,7 @@ const FeatureVariationDto = z.object({ z.string(), z.number(), z.boolean(), - z.array(z.any()), + z.array(z.unknown()), z.object({}).partial().passthrough(), ]), ) @@ -466,7 +484,7 @@ const CreateFeatureDto = z.object({ .record( z.string(), z.object({ - targets: z.array(z.any()).optional(), + targets: z.array(z.unknown()).optional(), status: z.string().optional(), }), ) @@ -490,7 +508,7 @@ const Variation = z.object({ z.string(), z.number(), z.boolean(), - z.array(z.any()), + z.array(z.unknown()), z.object({}).partial().passthrough(), ]), ) @@ -503,7 +521,7 @@ const CreateVariationDto = z.object({ .max(100) .regex(/^[a-z0-9-_.]+$/), name: z.string().max(100), - variables: z.record(z.any()).optional(), + variables: z.record(z.unknown()).optional(), }) const FeatureSettings = z.object({ publicName: z.string().max(100), @@ -553,7 +571,7 @@ const UpdateFeatureVariationDto = z z.string(), z.number(), z.boolean(), - z.array(z.any()), + z.array(z.unknown()), z.object({}).partial().passthrough(), ]), ), @@ -603,7 +621,7 @@ const Target = z.object({ _id: z.string(), name: z.string().optional(), audience: TargetAudience, - filters: z.array(z.any()).optional(), + filters: z.array(z.unknown()).optional(), rollout: Rollout.nullable().optional(), distribution: z.array(TargetDistribution), bucketingKey: z.string().optional(), diff --git a/src/mcp/server.schema.test.ts b/src/mcp/server.schema.test.ts new file mode 100644 index 000000000..dd2f38112 --- /dev/null +++ b/src/mcp/server.schema.test.ts @@ -0,0 +1,221 @@ +import { expect } from '@oclif/test' +import sinon from 'sinon' +import { registerAllToolsWithServer } from './tools' +import { DevCycleMCPServerInstance } from './server' + +// Type for tool configuration based on server definition +type ToolConfig = { + description: string + annotations?: any + inputSchema?: any + outputSchema?: any +} + +// Type for tool handler based on server definition +type ToolHandler = (args: any) => Promise + +describe('MCP Schema Validation', () => { + /** + * This test ensures that all MCP tool schemas are properly formatted for the MCP protocol. + * Specifically, it checks that array types have the required 'items' property. + * + * Background: The MCP protocol requires that all array types in JSON schemas must have + * an 'items' property. Using z.array(z.any()) in Zod schemas doesn't generate this + * property, causing MCP validation errors. + * + * Solution: Use z.array(z.unknown()) instead of z.array(z.any()) in zodClient.ts + */ + describe('Tool Registration Schema Validation', () => { + // Helper function to recursively check schemas + function validateSchemaArrays( + schema: any, + path: string, + errors: string[], + ): void { + if (!schema || typeof schema !== 'object') return + + // Check if this is an array type without items + if (schema.type === 'array' && !schema.items) { + errors.push( + `❌ Array at '${path}' is missing required 'items' property.\n` + + ` This will cause MCP validation errors.\n` + + ` Fix: In zodClient.ts, replace z.array(z.any()) with z.array(z.unknown())`, + ) + } + + // Recursively check nested schemas + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + validateSchemaArrays( + value, + `${path}.properties.${key}`, + errors, + ) + } + } + + if (schema.items) { + validateSchemaArrays(schema.items, `${path}.items`, errors) + } + + // Check schema combiners (anyOf, oneOf, allOf) + for (const combiner of ['anyOf', 'oneOf', 'allOf']) { + if (Array.isArray(schema[combiner])) { + schema[combiner].forEach((subSchema: any, idx: number) => { + validateSchemaArrays( + subSchema, + `${path}.${combiner}[${idx}]`, + errors, + ) + }) + } + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' + ) { + validateSchemaArrays( + schema.additionalProperties, + `${path}.additionalProperties`, + errors, + ) + } + } + + // Create a mock API client + const mockApiClient = { + executeWithLogging: sinon.stub(), + executeWithDashboardLink: sinon.stub(), + setSelectedProject: sinon.stub(), + hasProjectKey: sinon.stub().resolves(true), + getOrgId: sinon.stub().returns('test-org'), + getUserId: sinon.stub().returns('test-user'), + hasProjectAccess: sinon.stub().resolves(true), + getUserContext: sinon.stub().resolves({}), + } + + it('should register all tools with valid MCP schemas', () => { + const registeredTools: Array<{ + name: string + config: ToolConfig + }> = [] + + // Create a mock server instance that captures registrations + const mockServerInstance: DevCycleMCPServerInstance = { + registerToolWithErrorHandling: ( + name: string, + config: ToolConfig, + handler: ToolHandler, + ) => { + registeredTools.push({ name, config }) + }, + } + + // Register all tools + registerAllToolsWithServer(mockServerInstance, mockApiClient as any) + + // Validate that tools were registered + expect(registeredTools.length).to.be.greaterThan(0) + console.log( + `\n📊 Validating ${registeredTools.length} registered MCP tools...\n`, + ) + + // Track validation results + const validationErrors: string[] = [] + + // Validate each registered tool's schema + registeredTools.forEach(({ name, config }) => { + const errors: string[] = [] + + // Check if inputSchema exists (it should for all tools) + if (config.inputSchema) { + validateSchemaArrays( + config.inputSchema, + `${name}.inputSchema`, + errors, + ) + } + + // Also check outputSchema if it exists + if (config.outputSchema) { + validateSchemaArrays( + config.outputSchema, + `${name}.outputSchema`, + errors, + ) + } + + if (errors.length > 0) { + validationErrors.push( + `\n❌ Tool: ${name}`, + ...errors.map((e) => ` ${e}`), + ) + } + }) + + // Report results + if (validationErrors.length > 0) { + const errorMessage = [ + `\n🚨 MCP Schema Validation Failed\n`, + `Found ${validationErrors.length} tools with invalid schemas:`, + ...validationErrors, + `\n📝 How to fix:`, + `1. Open src/api/zodClient.ts`, + `2. Search for 'z.array(z.any())'`, + `3. Replace with 'z.array(z.unknown())'`, + `4. Also check for 'z.record(z.any())' and replace with 'z.record(z.unknown())'`, + `\nThis ensures proper JSON schema generation for MCP compatibility.`, + ].join('\n') + + expect.fail(errorMessage) + } else { + console.log( + `✅ All ${registeredTools.length} tools have valid MCP schemas!`, + ) + } + }) + + it('should validate each registered tool has required properties', () => { + const registeredTools: Array<{ + name: string + config: ToolConfig + }> = [] + + // Create a mock server instance that captures registrations + const mockServerInstance: DevCycleMCPServerInstance = { + registerToolWithErrorHandling: ( + name: string, + config: ToolConfig, + handler: ToolHandler, + ) => { + registeredTools.push({ name, config }) + }, + } + + // Register all tools + registerAllToolsWithServer(mockServerInstance, mockApiClient as any) + + // Validate each tool + registeredTools.forEach(({ name, config }) => { + // Every tool should have a description + expect(config).to.have.property('description') + expect(config.description).to.be.a('string') + expect(config.description.length).to.be.greaterThan(0) + + // Every tool should have inputSchema (even if empty) + expect(config).to.have.property('inputSchema') + + // If inputSchema is an object with properties, it should be a valid JSON schema + if ( + config.inputSchema && + typeof config.inputSchema === 'object' && + config.inputSchema.properties + ) { + expect(config.inputSchema).to.have.property('type') + expect(config.inputSchema.type).to.equal('object') + } + }) + }) + }) +})