Skip to content

Commit 20ddd2f

Browse files
authored
Fix $ref pointer resolution after output schema wrapping (#1435)
1 parent 480979b commit 20ddd2f

2 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,11 @@ typeProperty.ValueKind is not JsonValueKind.String ||
543543
["required"] = new JsonArray { (JsonNode)"result" }
544544
};
545545

546+
// After wrapping, any internal $ref pointers that used absolute JSON Pointer
547+
// paths (e.g., "#/items/..." or "#") are now invalid because the original schema
548+
// has moved under "#/properties/result". Rewrite them to account for the new location.
549+
RewriteRefPointers(schemaNode["properties"]!["result"]);
550+
546551
structuredOutputRequiresWrapping = true;
547552
}
548553

@@ -552,6 +557,52 @@ typeProperty.ValueKind is not JsonValueKind.String ||
552557
return outputSchema;
553558
}
554559

560+
/// <summary>
561+
/// Recursively rewrites all <c>$ref</c> JSON Pointer values in the given node
562+
/// to account for the schema having been wrapped under <c>properties.result</c>.
563+
/// </summary>
564+
/// <remarks>
565+
/// <c>System.Text.Json</c>'s <see cref="System.Text.Json.Schema.JsonSchemaExporter"/> uses absolute
566+
/// JSON Pointer paths (e.g., <c>#/items/properties/foo</c>) to deduplicate types that appear at
567+
/// multiple locations in the schema. When the original schema is moved under
568+
/// <c>#/properties/result</c> by the wrapping logic above, these pointers become unresolvable.
569+
/// This method prepends <c>/properties/result</c> to every <c>$ref</c> that starts with <c>#/</c>,
570+
/// and rewrites bare <c>#</c> (root self-references from recursive types) to <c>#/properties/result</c>,
571+
/// so the pointers remain valid after wrapping.
572+
/// </remarks>
573+
private static void RewriteRefPointers(JsonNode? node)
574+
{
575+
if (node is JsonObject obj)
576+
{
577+
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
578+
refNode?.GetValue<string>() is string refValue)
579+
{
580+
if (refValue == "#")
581+
{
582+
obj["$ref"] = "#/properties/result";
583+
}
584+
else if (refValue.StartsWith("#/", StringComparison.Ordinal))
585+
{
586+
obj["$ref"] = "#/properties/result" + refValue.Substring(1);
587+
}
588+
}
589+
590+
// Safe to iterate without snapshot: the $ref assignment above completes before
591+
// this enumerator is created, and recursive calls only mutate descendant objects.
592+
foreach (var property in obj)
593+
{
594+
RewriteRefPointers(property.Value);
595+
}
596+
}
597+
else if (node is JsonArray arr)
598+
{
599+
foreach (var item in arr)
600+
{
601+
RewriteRefPointers(item);
602+
}
603+
}
604+
}
605+
555606
private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
556607
{
557608
if (ProtocolTool.OutputSchema is null)

tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)