@@ -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