@@ -554,6 +554,180 @@ public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHan
554554 Assert . True ( JsonElement . DeepEquals ( expectedSchema , tool . ProtocolTool . InputSchema ) ) ;
555555 }
556556
557+ [ Fact ]
558+ public async Task StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers ( )
559+ {
560+ // When a non-object return type contains the same type at multiple locations,
561+ // System.Text.Json's schema exporter emits $ref pointers for deduplication.
562+ // After wrapping the schema under properties.result, those $ref pointers must
563+ // be rewritten to remain valid. This test verifies that fix.
564+ var data = new List < ContactInfo >
565+ {
566+ new ( )
567+ {
568+ WorkPhones = [ new ( ) { Number = "555-0100" , Type = "work" } ] ,
569+ HomePhones = [ new ( ) { Number = "555-0200" , Type = "home" } ] ,
570+ }
571+ } ;
572+
573+ JsonSerializerOptions options = new ( ) { TypeInfoResolver = new DefaultJsonTypeInfoResolver ( ) } ;
574+ McpServerTool tool = McpServerTool . Create ( ( ) => data , new ( ) { Name = "tool" , UseStructuredContent = true , SerializerOptions = options } ) ;
575+ var mockServer = new Mock < McpServer > ( ) ;
576+ var request = new RequestContext < CallToolRequestParams > ( mockServer . Object , CreateTestJsonRpcRequest ( ) )
577+ {
578+ Params = new CallToolRequestParams { Name = "tool" } ,
579+ } ;
580+
581+ var result = await tool . InvokeAsync ( request , TestContext . Current . CancellationToken ) ;
582+
583+ Assert . NotNull ( tool . ProtocolTool . OutputSchema ) ;
584+ Assert . Equal ( "object" , tool . ProtocolTool . OutputSchema . Value . GetProperty ( "type" ) . GetString ( ) ) ;
585+ Assert . NotNull ( result . StructuredContent ) ;
586+
587+ // Verify $ref pointers in the schema point to valid locations after wrapping.
588+ // Without the fix, $ref values like "#/items/..." would be unresolvable because
589+ // the original schema was moved under "#/properties/result".
590+ AssertMatchesJsonSchema ( tool . ProtocolTool . OutputSchema . Value , result . StructuredContent ) ;
591+
592+ // Also verify that any $ref in the schema starts with #/properties/result
593+ // (confirming the rewrite happened).
594+ string schemaJson = tool . ProtocolTool . OutputSchema . Value . GetRawText ( ) ;
595+ var schemaNode = JsonNode . Parse ( schemaJson ) ! ;
596+ AssertAllRefsStartWith ( schemaNode , "#/properties/result" ) ;
597+ AssertAllRefsResolvable ( schemaNode , schemaNode ) ;
598+ }
599+
600+ [ Fact ]
601+ public async Task StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers ( )
602+ {
603+ // When a non-object return type contains a recursive type, System.Text.Json's
604+ // schema exporter emits $ref pointers (including potentially bare "#") for the
605+ // recursive reference. After wrapping, these must be rewritten. For List<TreeNode>,
606+ // Children's items emit "$ref": "#/items" which must become "#/properties/result/items".
607+ var data = new List < TreeNode >
608+ {
609+ new ( )
610+ {
611+ Name = "root" ,
612+ Children = [ new ( ) { Name = "child" } ] ,
613+ }
614+ } ;
615+
616+ JsonSerializerOptions options = new ( ) { TypeInfoResolver = new DefaultJsonTypeInfoResolver ( ) } ;
617+ McpServerTool tool = McpServerTool . Create ( ( ) => data , new ( ) { Name = "tool" , UseStructuredContent = true , SerializerOptions = options } ) ;
618+ var mockServer = new Mock < McpServer > ( ) ;
619+ var request = new RequestContext < CallToolRequestParams > ( mockServer . Object , CreateTestJsonRpcRequest ( ) )
620+ {
621+ Params = new CallToolRequestParams { Name = "tool" } ,
622+ } ;
623+
624+ var result = await tool . InvokeAsync ( request , TestContext . Current . CancellationToken ) ;
625+
626+ Assert . NotNull ( tool . ProtocolTool . OutputSchema ) ;
627+ Assert . Equal ( "object" , tool . ProtocolTool . OutputSchema . Value . GetProperty ( "type" ) . GetString ( ) ) ;
628+ Assert . NotNull ( result . StructuredContent ) ;
629+
630+ AssertMatchesJsonSchema ( tool . ProtocolTool . OutputSchema . Value , result . StructuredContent ) ;
631+
632+ string schemaJson = tool . ProtocolTool . OutputSchema . Value . GetRawText ( ) ;
633+ var schemaNode = JsonNode . Parse ( schemaJson ) ! ;
634+ AssertAllRefsStartWith ( schemaNode , "#/properties/result" ) ;
635+ AssertAllRefsResolvable ( schemaNode , schemaNode ) ;
636+ }
637+
638+ private static void AssertAllRefsStartWith ( JsonNode ? node , string expectedPrefix )
639+ {
640+ if ( node is JsonObject obj )
641+ {
642+ if ( obj . TryGetPropertyValue ( "$ref" , out JsonNode ? refNode ) &&
643+ refNode ? . GetValue < string > ( ) is string refValue )
644+ {
645+ Assert . StartsWith ( expectedPrefix , refValue ) ;
646+ }
647+
648+ foreach ( var property in obj )
649+ {
650+ AssertAllRefsStartWith ( property . Value , expectedPrefix ) ;
651+ }
652+ }
653+ else if ( node is JsonArray arr )
654+ {
655+ foreach ( var item in arr )
656+ {
657+ AssertAllRefsStartWith ( item , expectedPrefix ) ;
658+ }
659+ }
660+ }
661+
662+ /// <summary>
663+ /// Walks the JSON tree and verifies that every <c>$ref</c> pointer resolves to a valid node.
664+ /// </summary>
665+ private static void AssertAllRefsResolvable ( JsonNode root , JsonNode ? node )
666+ {
667+ if ( node is JsonObject obj )
668+ {
669+ if ( obj . TryGetPropertyValue ( "$ref" , out JsonNode ? refNode ) &&
670+ refNode ? . GetValue < string > ( ) is string refValue &&
671+ refValue . StartsWith ( "#" , StringComparison . Ordinal ) )
672+ {
673+ var resolved = ResolveJsonPointer ( root , refValue ) ;
674+ Assert . True ( resolved is not null , $ "$ref \" { refValue } \" does not resolve to a valid node in the schema.") ;
675+ }
676+
677+ foreach ( var property in obj )
678+ {
679+ AssertAllRefsResolvable ( root , property . Value ) ;
680+ }
681+ }
682+ else if ( node is JsonArray arr )
683+ {
684+ foreach ( var item in arr )
685+ {
686+ AssertAllRefsResolvable ( root , item ) ;
687+ }
688+ }
689+ }
690+
691+ /// <summary>
692+ /// Resolves a JSON Pointer (e.g., <c>#/properties/result/items</c>) against a root node.
693+ /// Returns <c>null</c> if the pointer cannot be resolved.
694+ /// </summary>
695+ private static JsonNode ? ResolveJsonPointer ( JsonNode root , string pointer )
696+ {
697+ if ( pointer == "#" )
698+ {
699+ return root ;
700+ }
701+
702+ if ( ! pointer . StartsWith ( "#/" , StringComparison . Ordinal ) )
703+ {
704+ return null ;
705+ }
706+
707+ JsonNode ? current = root ;
708+ string [ ] segments = pointer . Substring ( 2 ) . Split ( '/' ) ;
709+ foreach ( string segment in segments )
710+ {
711+ if ( current is JsonObject obj )
712+ {
713+ if ( ! obj . TryGetPropertyValue ( segment , out current ) )
714+ {
715+ return null ;
716+ }
717+ }
718+ else if ( current is JsonArray arr && int . TryParse ( segment , out int index ) && index >= 0 && index < arr . Count )
719+ {
720+ current = arr [ index ] ;
721+ }
722+ else
723+ {
724+ return null ;
725+ }
726+ }
727+
728+ return current ;
729+ }
730+
557731 public static IEnumerable < object [ ] > StructuredOutput_ReturnsExpectedSchema_Inputs ( )
558732 {
559733 yield return new object [ ] { "string" } ;
@@ -679,6 +853,30 @@ Instance JSON document does not match the specified schema.
679853
680854 record Person ( string Name , int Age ) ;
681855
856+ // Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers.
857+ // ContactInfo has two properties of the same type (PhoneNumber) which causes
858+ // System.Text.Json's schema exporter to emit $ref pointers for deduplication.
859+ private sealed class PhoneNumber
860+ {
861+ public string ? Number { get ; set ; }
862+ public string ? Type { get ; set ; }
863+ }
864+
865+ private sealed class ContactInfo
866+ {
867+ public List < PhoneNumber > ? WorkPhones { get ; set ; }
868+ public List < PhoneNumber > ? HomePhones { get ; set ; }
869+ }
870+
871+ // Recursive type used by StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers.
872+ // When List<TreeNode> is the return type, Children's items emit "$ref": "#/items"
873+ // pointing back to the first TreeNode definition, which must be rewritten after wrapping.
874+ private sealed class TreeNode
875+ {
876+ public string ? Name { get ; set ; }
877+ public List < TreeNode > ? Children { get ; set ; }
878+ }
879+
682880 [ Fact ]
683881 public void SupportsIconsInCreateOptions ( )
684882 {
0 commit comments