@@ -554,6 +554,72 @@ 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+ }
598+
599+ private static void AssertAllRefsStartWith ( JsonNode ? node , string expectedPrefix )
600+ {
601+ if ( node is JsonObject obj )
602+ {
603+ if ( obj . TryGetPropertyValue ( "$ref" , out JsonNode ? refNode ) &&
604+ refNode ? . GetValue < string > ( ) is string refValue )
605+ {
606+ Assert . StartsWith ( expectedPrefix , refValue ) ;
607+ }
608+
609+ foreach ( var property in obj )
610+ {
611+ AssertAllRefsStartWith ( property . Value , expectedPrefix ) ;
612+ }
613+ }
614+ else if ( node is JsonArray arr )
615+ {
616+ foreach ( var item in arr )
617+ {
618+ AssertAllRefsStartWith ( item , expectedPrefix ) ;
619+ }
620+ }
621+ }
622+
557623 public static IEnumerable < object [ ] > StructuredOutput_ReturnsExpectedSchema_Inputs ( )
558624 {
559625 yield return new object [ ] { "string" } ;
@@ -679,6 +745,21 @@ Instance JSON document does not match the specified schema.
679745
680746 record Person ( string Name , int Age ) ;
681747
748+ // Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers.
749+ // ContactInfo has two properties of the same type (PhoneNumber) which causes
750+ // System.Text.Json's schema exporter to emit $ref pointers for deduplication.
751+ private sealed class PhoneNumber
752+ {
753+ public string ? Number { get ; set ; }
754+ public string ? Type { get ; set ; }
755+ }
756+
757+ private sealed class ContactInfo
758+ {
759+ public List < PhoneNumber > ? WorkPhones { get ; set ; }
760+ public List < PhoneNumber > ? HomePhones { get ; set ; }
761+ }
762+
682763 [ Fact ]
683764 public void SupportsIconsInCreateOptions ( )
684765 {
0 commit comments