Skip to content

Commit 3161f3c

Browse files
authored
Merge pull request #162 from QuantGeekDev/test/112-schema-regression
test: add regression tests for schema output (issue #112)
2 parents a385629 + e06119c commit 3161f3c

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

tests/tools/BaseTool.test.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,250 @@ describe('BaseTool', () => {
503503
});
504504
});
505505

506+
describe('Schema regression: no raw Zod internals in output (issue #112)', () => {
507+
// Regression test for https://github.com/QuantGeekDev/mcp-framework/issues/112
508+
// In v0.2.14, tool schemas emitted raw Zod internals (_def, typeName, ~standard)
509+
// instead of proper JSON Schema (type, properties, required, description).
510+
511+
function assertNoZodInternals(obj: unknown, path = 'root'): void {
512+
if (obj === null || obj === undefined || typeof obj !== 'object') return;
513+
const record = obj as Record<string, unknown>;
514+
expect(record).not.toHaveProperty('_def');
515+
expect(record).not.toHaveProperty('typeName');
516+
expect(record).not.toHaveProperty('~standard');
517+
expect(record).not.toHaveProperty('coerce');
518+
for (const [key, value] of Object.entries(record)) {
519+
if (typeof value === 'object' && value !== null) {
520+
assertNoZodInternals(value, `${path}.${key}`);
521+
}
522+
}
523+
}
524+
525+
it('should produce valid JSON Schema from a simple Zod object schema', () => {
526+
const schema = z.object({
527+
location: z
528+
.string()
529+
.describe("Location to get weather for (e.g., 'Paris', 'New York')"),
530+
});
531+
532+
class WeatherTool extends MCPTool<z.infer<typeof schema>, typeof schema> {
533+
name = 'weather';
534+
description = 'Get weather information for a specific location';
535+
schema = schema;
536+
protected async execute(input: z.infer<typeof schema>) {
537+
return { location: input.location };
538+
}
539+
}
540+
541+
const tool = new WeatherTool();
542+
const definition = tool.toolDefinition;
543+
544+
// Exact structure from the issue's "expected" (v0.2.13) output
545+
expect(definition).toEqual({
546+
name: 'weather',
547+
description: 'Get weather information for a specific location',
548+
inputSchema: {
549+
type: 'object',
550+
properties: {
551+
location: {
552+
type: 'string',
553+
description: "Location to get weather for (e.g., 'Paris', 'New York')",
554+
},
555+
},
556+
required: ['location'],
557+
},
558+
});
559+
});
560+
561+
it('should never contain raw Zod internals in Zod object schema output', () => {
562+
const schema = z.object({
563+
query: z.string().describe('Search query'),
564+
limit: z.number().int().positive().optional().default(10).describe('Max results'),
565+
tags: z.array(z.string().describe('Tag value')).optional().describe('Filter tags'),
566+
sortBy: z
567+
.enum(['relevance', 'date', 'price'])
568+
.optional()
569+
.default('relevance')
570+
.describe('Sort order'),
571+
filters: z
572+
.object({
573+
minPrice: z.number().optional().describe('Minimum price'),
574+
maxPrice: z.number().optional().describe('Maximum price'),
575+
})
576+
.optional()
577+
.describe('Price filters'),
578+
});
579+
580+
class SearchTool extends MCPTool<z.infer<typeof schema>, typeof schema> {
581+
name = 'search';
582+
description = 'Search items';
583+
schema = schema;
584+
protected async execute(input: z.infer<typeof schema>) {
585+
return input;
586+
}
587+
}
588+
589+
const tool = new SearchTool();
590+
const definition = tool.toolDefinition;
591+
592+
// Recursively verify no Zod internals leaked
593+
assertNoZodInternals(definition);
594+
595+
// Verify it's valid JSON Schema structure
596+
expect(definition.inputSchema.type).toBe('object');
597+
expect(definition.inputSchema.properties).toBeDefined();
598+
expect(typeof definition.inputSchema.properties).toBe('object');
599+
600+
const props = definition.inputSchema.properties!;
601+
602+
// Every property must have a string 'type' field
603+
for (const [key, value] of Object.entries(props)) {
604+
expect((value as any).type).toEqual(expect.any(String));
605+
expect((value as any).description).toEqual(expect.any(String));
606+
}
607+
608+
// Verify specific types
609+
expect((props.query as any).type).toBe('string');
610+
expect((props.limit as any).type).toBe('integer');
611+
expect((props.tags as any).type).toBe('array');
612+
expect((props.sortBy as any).type).toBe('string');
613+
expect((props.sortBy as any).enum).toEqual(['relevance', 'date', 'price']);
614+
expect((props.filters as any).type).toBe('object');
615+
expect((props.filters as any).properties).toBeDefined();
616+
});
617+
618+
it('should never contain raw Zod internals in legacy schema output', () => {
619+
interface LegacyInput {
620+
name: string;
621+
age: number;
622+
active?: boolean;
623+
}
624+
625+
class LegacyTool extends MCPTool<LegacyInput> {
626+
name = 'legacy_tool';
627+
description = 'Tool with legacy schema format';
628+
schema = {
629+
name: { type: z.string(), description: 'User name' },
630+
age: { type: z.number(), description: 'User age' },
631+
active: { type: z.boolean().optional(), description: 'Is active' },
632+
};
633+
protected async execute(input: LegacyInput) {
634+
return input;
635+
}
636+
}
637+
638+
const tool = new LegacyTool();
639+
const definition = tool.toolDefinition;
640+
641+
assertNoZodInternals(definition);
642+
643+
const props = definition.inputSchema.properties!;
644+
expect((props.name as any).type).toBe('string');
645+
expect((props.age as any).type).toBe('number');
646+
expect((props.active as any).type).toBe('boolean');
647+
});
648+
649+
it('should produce JSON-serializable output with no circular references', () => {
650+
const schema = z.object({
651+
nested: z
652+
.object({
653+
items: z
654+
.array(
655+
z.object({
656+
id: z.number().describe('Item ID'),
657+
label: z.string().describe('Item label'),
658+
})
659+
)
660+
.describe('List of items'),
661+
})
662+
.describe('Nested object'),
663+
});
664+
665+
class NestedTool extends MCPTool<z.infer<typeof schema>, typeof schema> {
666+
name = 'nested_tool';
667+
description = 'Tool with deeply nested schema';
668+
schema = schema;
669+
protected async execute(input: z.infer<typeof schema>) {
670+
return input;
671+
}
672+
}
673+
674+
const tool = new NestedTool();
675+
const definition = tool.toolDefinition;
676+
677+
// Must survive JSON round-trip without loss
678+
const serialized = JSON.stringify(definition);
679+
const deserialized = JSON.parse(serialized);
680+
expect(deserialized).toEqual(definition);
681+
682+
assertNoZodInternals(deserialized);
683+
684+
// Verify nested structure
685+
const nested = (deserialized.inputSchema.properties.nested as any);
686+
expect(nested.type).toBe('object');
687+
expect(nested.properties.items.type).toBe('array');
688+
expect(nested.properties.items.items.type).toBe('object');
689+
expect(nested.properties.items.items.properties.id.type).toBe('number');
690+
expect(nested.properties.items.items.properties.label.type).toBe('string');
691+
});
692+
693+
it('should preserve string constraints as JSON Schema properties, not Zod checks', () => {
694+
const schema = z.object({
695+
email: z.string().email().describe('Email address'),
696+
url: z.string().url().describe('Website URL'),
697+
code: z.string().min(3).max(10).describe('Short code'),
698+
pattern: z.string().regex(/^[A-Z]+$/).describe('Uppercase only'),
699+
});
700+
701+
class ConstraintTool extends MCPTool<z.infer<typeof schema>, typeof schema> {
702+
name = 'constraint_tool';
703+
description = 'Tool with string constraints';
704+
schema = schema;
705+
protected async execute(input: z.infer<typeof schema>) {
706+
return input;
707+
}
708+
}
709+
710+
const tool = new ConstraintTool();
711+
const props = tool.inputSchema.properties!;
712+
713+
assertNoZodInternals(props);
714+
715+
expect((props.email as any).format).toBe('email');
716+
expect((props.url as any).format).toBe('uri');
717+
expect((props.code as any).minLength).toBe(3);
718+
expect((props.code as any).maxLength).toBe(10);
719+
expect((props.pattern as any).pattern).toBe('^[A-Z]+$');
720+
});
721+
722+
it('should preserve number constraints as JSON Schema properties, not Zod checks', () => {
723+
const schema = z.object({
724+
age: z.number().int().positive().describe('Age'),
725+
score: z.number().min(0).max(100).describe('Score'),
726+
});
727+
728+
class NumConstraintTool extends MCPTool<z.infer<typeof schema>, typeof schema> {
729+
name = 'num_constraint_tool';
730+
description = 'Tool with number constraints';
731+
schema = schema;
732+
protected async execute(input: z.infer<typeof schema>) {
733+
return input;
734+
}
735+
}
736+
737+
const tool = new NumConstraintTool();
738+
const props = tool.inputSchema.properties!;
739+
740+
assertNoZodInternals(props);
741+
742+
expect((props.age as any).type).toBe('integer');
743+
expect((props.age as any).minimum).toBe(1);
744+
expect((props.score as any).type).toBe('number');
745+
expect((props.score as any).minimum).toBe(0);
746+
expect((props.score as any).maximum).toBe(100);
747+
});
748+
});
749+
506750
describe('Sampling', () => {
507751
// Expose the protected samplingRequest for direct testing
508752
class SamplingTestTool extends MCPTool {

0 commit comments

Comments
 (0)