@@ -794,6 +794,186 @@ public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHan
794794 Assert . True ( JsonElement . DeepEquals ( expectedSchema , tool . ProtocolTool . InputSchema ) ) ;
795795 }
796796
797+ [ Fact ]
798+ public async Task StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers ( )
799+ {
800+ // When a non-object return type contains the same type at multiple locations,
801+ // System.Text.Json's schema exporter emits $ref pointers for deduplication.
802+ // After wrapping the schema under properties.result, those $ref pointers must
803+ // be rewritten to remain valid. This test verifies that fix.
804+ var data = new List < ContactInfo >
805+ {
806+ new ( )
807+ {
808+ WorkPhones = [ new ( ) { Number = "555-0100" , Type = "work" } ] ,
809+ HomePhones = [ new ( ) { Number = "555-0200" , Type = "home" } ] ,
810+ }
811+ } ;
812+
813+ JsonSerializerOptions options = new ( ) { TypeInfoResolver = new DefaultJsonTypeInfoResolver ( ) } ;
814+ McpServerTool tool = McpServerTool . Create ( ( ) => data , new ( ) { Name = "tool" , UseStructuredContent = true , SerializerOptions = options } ) ;
815+ var mockServer = new Mock < McpServer > ( ) ;
816+ var result = await tool . InvokeAsync (
817+ new RequestContext < CallToolRequestParams > ( mockServer . Object , CreateTestJsonRpcRequest ( ) , new ( ) { Name = "tool" } ) ,
818+ TestContext . Current . CancellationToken ) ;
819+
820+ Assert . NotNull ( tool . ProtocolTool . OutputSchema ) ;
821+ Assert . Equal ( "object" , tool . ProtocolTool . OutputSchema . Value . GetProperty ( "type" ) . GetString ( ) ) ;
822+ Assert . NotNull ( result . StructuredContent ) ;
823+
824+ // Verify $ref pointers in the schema point to valid locations after wrapping.
825+ // Without the fix, $ref values like "#/items/..." would be unresolvable because
826+ // the original schema was moved under "#/properties/result".
827+ AssertMatchesJsonSchema ( tool . ProtocolTool . OutputSchema . Value , result . StructuredContent ) ;
828+
829+ // Also verify that any $ref in the schema starts with #/properties/result
830+ // (confirming the rewrite happened).
831+ string schemaJson = tool . ProtocolTool . OutputSchema . Value . GetRawText ( ) ;
832+ var schemaNode = JsonNode . Parse ( schemaJson ) ! ;
833+ int refCount = AssertAllRefsStartWith ( schemaNode , "#/properties/result" ) ;
834+ Assert . True ( refCount > 0 , "Expected at least one $ref in the schema to validate the rewrite, but none were found." ) ;
835+ int resolvableCount = AssertAllRefsResolvable ( schemaNode , schemaNode ) ;
836+ Assert . True ( resolvableCount > 0 , "Expected at least one resolvable $ref in the schema, but none were found." ) ;
837+ }
838+
839+ [ Fact ]
840+ public async Task StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers ( )
841+ {
842+ // When a non-object return type contains a recursive type, System.Text.Json's
843+ // schema exporter emits $ref pointers (including potentially bare "#") for the
844+ // recursive reference. After wrapping, these must be rewritten. For List<TreeNode>,
845+ // Children's items emit "$ref": "#/items" which must become "#/properties/result/items".
846+ var data = new List < TreeNode >
847+ {
848+ new ( )
849+ {
850+ Name = "root" ,
851+ Children = [ new ( ) { Name = "child" } ] ,
852+ }
853+ } ;
854+
855+ JsonSerializerOptions options = new ( ) { TypeInfoResolver = new DefaultJsonTypeInfoResolver ( ) } ;
856+ McpServerTool tool = McpServerTool . Create ( ( ) => data , new ( ) { Name = "tool" , UseStructuredContent = true , SerializerOptions = options } ) ;
857+ var mockServer = new Mock < McpServer > ( ) ;
858+ var result = await tool . InvokeAsync (
859+ new RequestContext < CallToolRequestParams > ( mockServer . Object , CreateTestJsonRpcRequest ( ) , new ( ) { Name = "tool" } ) ,
860+ TestContext . Current . CancellationToken ) ;
861+
862+ Assert . NotNull ( tool . ProtocolTool . OutputSchema ) ;
863+ Assert . Equal ( "object" , tool . ProtocolTool . OutputSchema . Value . GetProperty ( "type" ) . GetString ( ) ) ;
864+ Assert . NotNull ( result . StructuredContent ) ;
865+
866+ AssertMatchesJsonSchema ( tool . ProtocolTool . OutputSchema . Value , result . StructuredContent ) ;
867+
868+ string schemaJson = tool . ProtocolTool . OutputSchema . Value . GetRawText ( ) ;
869+ var schemaNode = JsonNode . Parse ( schemaJson ) ! ;
870+ int refCount = AssertAllRefsStartWith ( schemaNode , "#/properties/result" ) ;
871+ Assert . True ( refCount > 0 , "Expected at least one $ref in the schema to validate the rewrite, but none were found." ) ;
872+ int resolvableCount = AssertAllRefsResolvable ( schemaNode , schemaNode ) ;
873+ Assert . True ( resolvableCount > 0 , "Expected at least one resolvable $ref in the schema, but none were found." ) ;
874+ }
875+
876+ private static int AssertAllRefsStartWith ( JsonNode ? node , string expectedPrefix )
877+ {
878+ int count = 0 ;
879+ if ( node is JsonObject obj )
880+ {
881+ if ( obj . TryGetPropertyValue ( "$ref" , out JsonNode ? refNode ) &&
882+ refNode ? . GetValue < string > ( ) is string refValue )
883+ {
884+ Assert . StartsWith ( expectedPrefix , refValue ) ;
885+ count ++ ;
886+ }
887+
888+ foreach ( var property in obj )
889+ {
890+ count += AssertAllRefsStartWith ( property . Value , expectedPrefix ) ;
891+ }
892+ }
893+ else if ( node is JsonArray arr )
894+ {
895+ foreach ( var item in arr )
896+ {
897+ count += AssertAllRefsStartWith ( item , expectedPrefix ) ;
898+ }
899+ }
900+
901+ return count ;
902+ }
903+
904+ /// <summary>
905+ /// Walks the JSON tree and verifies that every <c>$ref</c> pointer resolves to a valid node.
906+ /// </summary>
907+ private static int AssertAllRefsResolvable ( JsonNode root , JsonNode ? node )
908+ {
909+ int count = 0 ;
910+ if ( node is JsonObject obj )
911+ {
912+ if ( obj . TryGetPropertyValue ( "$ref" , out JsonNode ? refNode ) &&
913+ refNode ? . GetValue < string > ( ) is string refValue &&
914+ refValue . StartsWith ( "#" , StringComparison . Ordinal ) )
915+ {
916+ var resolved = ResolveJsonPointer ( root , refValue ) ;
917+ Assert . True ( resolved is not null , $ "$ref \" { refValue } \" does not resolve to a valid node in the schema.") ;
918+ count ++ ;
919+ }
920+
921+ foreach ( var property in obj )
922+ {
923+ count += AssertAllRefsResolvable ( root , property . Value ) ;
924+ }
925+ }
926+ else if ( node is JsonArray arr )
927+ {
928+ foreach ( var item in arr )
929+ {
930+ count += AssertAllRefsResolvable ( root , item ) ;
931+ }
932+ }
933+
934+ return count ;
935+ }
936+
937+ /// <summary>
938+ /// Resolves a JSON Pointer (e.g., <c>#/properties/result/items</c>) against a root node.
939+ /// Returns <c>null</c> if the pointer cannot be resolved.
940+ /// </summary>
941+ private static JsonNode ? ResolveJsonPointer ( JsonNode root , string pointer )
942+ {
943+ if ( pointer == "#" )
944+ {
945+ return root ;
946+ }
947+
948+ if ( ! pointer . StartsWith ( "#/" , StringComparison . Ordinal ) )
949+ {
950+ return null ;
951+ }
952+
953+ JsonNode ? current = root ;
954+ string [ ] segments = pointer . Substring ( 2 ) . Split ( '/' ) ;
955+ foreach ( string segment in segments )
956+ {
957+ if ( current is JsonObject obj )
958+ {
959+ if ( ! obj . TryGetPropertyValue ( segment , out current ) )
960+ {
961+ return null ;
962+ }
963+ }
964+ else if ( current is JsonArray arr && int . TryParse ( segment , out int index ) && index >= 0 && index < arr . Count )
965+ {
966+ current = arr [ index ] ;
967+ }
968+ else
969+ {
970+ return null ;
971+ }
972+ }
973+
974+ return current ;
975+ }
976+
797977 public static IEnumerable < object [ ] > StructuredOutput_ReturnsExpectedSchema_Inputs ( )
798978 {
799979 yield return new object [ ] { "string" } ;
@@ -926,6 +1106,30 @@ private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
9261106 return options ;
9271107 }
9281108
1109+ // Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers.
1110+ // ContactInfo has two properties of the same type (PhoneNumber) which causes
1111+ // System.Text.Json's schema exporter to emit $ref pointers for deduplication.
1112+ private sealed class PhoneNumber
1113+ {
1114+ public string ? Number { get ; set ; }
1115+ public string ? Type { get ; set ; }
1116+ }
1117+
1118+ private sealed class ContactInfo
1119+ {
1120+ public List < PhoneNumber > ? WorkPhones { get ; set ; }
1121+ public List < PhoneNumber > ? HomePhones { get ; set ; }
1122+ }
1123+
1124+ // Recursive type used by StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers.
1125+ // When List<TreeNode> is the return type, Children's items emit "$ref": "#/items"
1126+ // pointing back to the first TreeNode definition, which must be rewritten after wrapping.
1127+ private sealed class TreeNode
1128+ {
1129+ public string ? Name { get ; set ; }
1130+ public List < TreeNode > ? Children { get ; set ; }
1131+ }
1132+
9291133 [ Fact ]
9301134 public void SupportsIconsInCreateOptions ( )
9311135 {
0 commit comments