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
20 changes: 7 additions & 13 deletions src/cli/project/add-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<this>) {
return \`Processed: \${input.message}\`;
}
}
Expand Down
40 changes: 14 additions & 26 deletions src/cli/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExampleInput> {
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<this>, context?: any) {
// Access authentication claims from OAuth token
const claims = context?.auth?.data;
const userId = claims?.sub || 'unknown';
Expand All @@ -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<ExampleInput> {
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<this>) {
return \`Processed: \${input.message}\`;
}
}
Expand Down
26 changes: 24 additions & 2 deletions src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,31 @@ export abstract class MCPTool<TInput extends Record<string, any> = any, TSchema
get inputSchema(): { type: 'object'; properties?: Record<string, unknown>; required?: string[] } {
if (this.isZodObjectSchema(this.schema)) {
return this.generateSchemaFromZodObject(this.schema);
} else {
return this.generateSchemaFromLegacyFormat(this.schema as ToolInputSchema<TInput>);
}

// 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<string, any>);
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<TInput>);
}

private generateSchemaFromZodObject(zodSchema: z.ZodObject<any>): {
Expand Down
88 changes: 88 additions & 0 deletions tests/tools/schema-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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:/);
});
});