From 96a8c70b752bf13eebb4623ac3f86d5c05dbd20b Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Wed, 1 Apr 2026 19:03:09 +0200 Subject: [PATCH] fix: add helpful error for incorrect schema format and update CLI templates to Zod-first Closes #121 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/project/add-tool.ts | 20 +++--- src/cli/project/create.ts | 40 +++++------- src/tools/BaseTool.ts | 26 +++++++- tests/tools/schema-validation.test.ts | 88 +++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 41 deletions(-) create mode 100644 tests/tools/schema-validation.test.ts diff --git a/src/cli/project/add-tool.ts b/src/cli/project/add-tool.ts index 638aeb4..eafe04a 100644 --- a/src/cli/project/add-tool.ts +++ b/src/cli/project/add-tool.ts @@ -40,25 +40,19 @@ export async function addTool(name?: string) { try { await mkdir(toolsDir, { recursive: true }); - const toolContent = `import { MCPTool } from "mcp-framework"; + const toolContent = `import { MCPTool, MCPInput } from "mcp-framework"; import { z } from "zod"; -interface ${className}Input { - message: string; -} +const schema = z.object({ + message: z.string().describe("Message to process"), +}); -class ${className}Tool extends MCPTool<${className}Input> { +class ${className}Tool extends MCPTool { name = "${toolName}"; description = "${className} tool description"; + schema = schema; - schema = { - message: { - type: z.string(), - description: "Message to process", - }, - }; - - async execute(input: ${className}Input) { + async execute(input: MCPInput) { return \`Processed: \${input.message}\`; } } diff --git a/src/cli/project/create.ts b/src/cli/project/create.ts index e2ec607..4a3307f 100644 --- a/src/cli/project/create.ts +++ b/src/cli/project/create.ts @@ -208,25 +208,19 @@ server.start();`; // Generate example tool (OAuth-aware if OAuth is enabled) const exampleToolTs = options?.oauth - ? `import { MCPTool } from "mcp-framework"; + ? `import { MCPTool, MCPInput } from "mcp-framework"; import { z } from "zod"; -interface ExampleInput { - message: string; -} +const schema = z.object({ + message: z.string().describe("Message to process"), +}); -class ExampleTool extends MCPTool { +class ExampleTool extends MCPTool { name = "example_tool"; description = "An example authenticated tool that processes messages"; + schema = schema; - schema = { - message: { - type: z.string(), - description: "Message to process", - }, - }; - - async execute(input: ExampleInput, context?: any) { + async execute(input: MCPInput, context?: any) { // Access authentication claims from OAuth token const claims = context?.auth?.data; const userId = claims?.sub || 'unknown'; @@ -239,25 +233,19 @@ Token scope: \${scope}\`; } export default ExampleTool;` - : `import { MCPTool } from "mcp-framework"; + : `import { MCPTool, MCPInput } from "mcp-framework"; import { z } from "zod"; -interface ExampleInput { - message: string; -} +const schema = z.object({ + message: z.string().describe("Message to process"), +}); -class ExampleTool extends MCPTool { +class ExampleTool extends MCPTool { name = "example_tool"; description = "An example tool that processes messages"; + schema = schema; - schema = { - message: { - type: z.string(), - description: "Message to process", - }, - }; - - async execute(input: ExampleInput) { + async execute(input: MCPInput) { return \`Processed: \${input.message}\`; } } diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index bdaf207..b51e284 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -150,9 +150,31 @@ export abstract class MCPTool = any, TSchema get inputSchema(): { type: 'object'; properties?: Record; required?: string[] } { if (this.isZodObjectSchema(this.schema)) { return this.generateSchemaFromZodObject(this.schema); - } else { - return this.generateSchemaFromLegacyFormat(this.schema as ToolInputSchema); } + + // Check for common mistake: plain object with Zod types instead of z.object() + if (typeof this.schema === 'object' && this.schema !== null && !this.isZodObjectSchema(this.schema)) { + const entries = Object.entries(this.schema as Record); + const hasRawZodValues = entries.some(([_, value]) => { + return value instanceof z.ZodType && !('type' in value && 'description' in value); + }); + + if (hasRawZodValues) { + throw new Error( + `Invalid schema format in tool "${this.name}". ` + + `It looks like you passed a plain object with Zod types. ` + + `Use z.object() instead:\n\n` + + ` // Wrong:\n` + + ` schema = { field: z.string() }\n\n` + + ` // Correct:\n` + + ` schema = z.object({\n` + + ` field: z.string().describe("Field description")\n` + + ` })` + ); + } + } + + return this.generateSchemaFromLegacyFormat(this.schema as ToolInputSchema); } private generateSchemaFromZodObject(zodSchema: z.ZodObject): { diff --git a/tests/tools/schema-validation.test.ts b/tests/tools/schema-validation.test.ts new file mode 100644 index 0000000..0402d6c --- /dev/null +++ b/tests/tools/schema-validation.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from '@jest/globals'; +import { z } from 'zod'; +import { MCPTool } from '../../src/tools/BaseTool.js'; + +describe('Schema validation error messages', () => { + it('should throw a helpful error when schema is a plain object with raw Zod types', () => { + class BadTool extends MCPTool { + name = 'bad_tool'; + description = 'A tool with incorrect schema'; + schema = { + message: z.string(), + count: z.number(), + } as any; + + async execute(input: any) { + return input; + } + } + + const tool = new BadTool(); + expect(() => tool.inputSchema).toThrow(/Invalid schema format/); + expect(() => tool.inputSchema).toThrow(/Use z\.object\(\) instead/); + expect(() => tool.inputSchema).toThrow(/bad_tool/); + }); + + it('should NOT throw for valid legacy format schemas', () => { + class LegacyTool extends MCPTool<{ message: string }> { + name = 'legacy_tool'; + description = 'A tool with legacy schema'; + schema = { + message: { + type: z.string(), + description: 'A message', + }, + }; + + async execute(input: { message: string }) { + return input; + } + } + + const tool = new LegacyTool(); + expect(() => tool.inputSchema).not.toThrow(); + }); + + it('should NOT throw for valid Zod object schemas', () => { + class ZodTool extends MCPTool { + name = 'zod_tool'; + description = 'A tool with Zod schema'; + schema = z.object({ + message: z.string().describe('A message'), + }); + + async execute(input: any) { + return input; + } + } + + const tool = new ZodTool(); + expect(() => tool.inputSchema).not.toThrow(); + }); +}); + +describe('CLI templates use Zod-first pattern', () => { + it('add-tool template should use z.object() pattern', async () => { + const { readFileSync } = await import('fs'); + const content = readFileSync('src/cli/project/add-tool.ts', 'utf-8'); + + // Should use z.object pattern + expect(content).toContain('z.object({'); + expect(content).toContain('.describe('); + // Should import MCPInput + expect(content).toContain('MCPInput'); + // Should NOT have legacy format with { type: z.string(), description: ... } + expect(content).not.toMatch(/type:\s*z\.string\(\)/); + }); + + it('create template should use z.object() pattern', async () => { + const { readFileSync } = await import('fs'); + const content = readFileSync('src/cli/project/create.ts', 'utf-8'); + + // Should use z.object pattern in example tools + expect(content).toContain('z.object({'); + expect(content).toContain('.describe('); + // Should NOT have legacy format + expect(content).not.toMatch(/type:\s*z\.string\(\),\s*\n\s*description:/); + }); +});