Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/api/zodClient.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
]),
)
Expand Down Expand Up @@ -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(),
}),
)
Expand All @@ -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(),
]),
)
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
]),
),
Expand Down Expand Up @@ -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(),
Expand Down
221 changes: 221 additions & 0 deletions src/mcp/server.schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>

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,
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'any' type defeats TypeScript's type safety. Consider using a more specific type like 'object | unknown' or define a proper interface for the schema structure.

Suggested change
schema: any,
schema: unknown,

Copilot uses AI. Check for mistakes.
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) => {
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'any' type for subSchema defeats TypeScript's type safety. Consider using 'unknown' or a more specific type.

Suggested change
schema[combiner].forEach((subSchema: any, idx: number) => {
schema[combiner].forEach((subSchema: unknown, idx: number) => {

Copilot uses AI. Check for mistakes.
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({}),
}

Comment on lines +86 to +97
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock API client is duplicated in both test cases (lines 86-96 and implicitly recreated in the second test). Consider extracting this to a helper function or beforeEach block to reduce code duplication.

Suggested change
// 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({}),
}
// Helper to create a fresh mock API client for each test
function createMockApiClient() {
return {
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({}),
}
}
let mockApiClient: ReturnType<typeof createMockApiClient>
beforeEach(() => {
mockApiClient = createMockApiClient()
})

Copilot uses AI. Check for mistakes.
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) {
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable validationErrors is being incremented with individual tool errors, but the final error message states 'Found ${validationErrors.length} tools with invalid schemas' which counts array elements, not tools. This will show an incorrect count since each tool can have multiple errors. Consider tracking the number of invalid tools separately.

Suggested change
if (errors.length > 0) {
if (errors.length > 0) {
invalidToolCount++

Copilot uses AI. Check for mistakes.
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())'`,
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message suggests checking for z.record(z.any()), but the validation function validateSchemaArrays only checks for array types with missing items, not record types. Either add validation for record types or remove this suggestion from the error message.

Suggested change
`4. Also check for 'z.record(z.any())' and replace with 'z.record(z.unknown())'`,

Copilot uses AI. Check for mistakes.
`\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')
}
})
})
})
})