Skip to content

Commit 9c68a58

Browse files
committed
Fix pointer resolution after output schema wrapping
When CreateOutputSchema() wraps a non-object schema under properties.result, internal $ref JSON Pointers emitted by System.Text.Json's JsonSchemaExporter become unresolvable because they still point to the original (unwrapped) paths. Add RewriteRefPointers() to prepend /properties/result to every $ref that starts with #/, keeping the pointers valid after wrapping. Fixes #1434
1 parent aae77b1 commit 9c68a58

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

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

523+
// After wrapping, any internal $ref pointers that used absolute JSON Pointer
524+
// paths (e.g., "#/items/..." or "#") are now invalid because the original schema
525+
// has moved under "#/properties/result". Rewrite them to account for the new location.
526+
RewriteRefPointers(schemaNode["properties"]!["result"]);
527+
523528
structuredOutputRequiresWrapping = true;
524529
}
525530

@@ -529,6 +534,51 @@ typeProperty.ValueKind is not JsonValueKind.String ||
529534
return outputSchema;
530535
}
531536

537+
/// <summary>
538+
/// Recursively rewrites all <c>$ref</c> JSON Pointer values in the given node
539+
/// to account for the schema having been wrapped under <c>properties.result</c>.
540+
/// </summary>
541+
/// <remarks>
542+
/// <c>System.Text.Json</c>'s <see cref="System.Text.Json.Schema.JsonSchemaExporter"/> uses absolute
543+
/// JSON Pointer paths (e.g., <c>#/items/properties/foo</c>) to deduplicate types that appear at
544+
/// multiple locations in the schema. When the original schema is moved under
545+
/// <c>#/properties/result</c> by the wrapping logic above, these pointers become unresolvable.
546+
/// This method prepends <c>/properties/result</c> to every <c>$ref</c> that starts with <c>#/</c>,
547+
/// and rewrites bare <c>#</c> (root self-references from recursive types) to <c>#/properties/result</c>,
548+
/// so the pointers remain valid after wrapping.
549+
/// </remarks>
550+
private static void RewriteRefPointers(JsonNode? node)
551+
{
552+
if (node is JsonObject obj)
553+
{
554+
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
555+
refNode?.GetValue<string>() is string refValue)
556+
{
557+
if (refValue == "#")
558+
{
559+
obj["$ref"] = "#/properties/result";
560+
}
561+
else if (refValue.StartsWith("#/", StringComparison.Ordinal))
562+
{
563+
obj["$ref"] = "#/properties/result" + refValue.Substring(1);
564+
}
565+
}
566+
567+
// ToList() creates a snapshot because the $ref assignment above may invalidate the enumerator.
568+
foreach (var property in obj.ToList())
569+
{
570+
RewriteRefPointers(property.Value);
571+
}
572+
}
573+
else if (node is JsonArray arr)
574+
{
575+
foreach (var item in arr)
576+
{
577+
RewriteRefPointers(item);
578+
}
579+
}
580+
}
581+
532582
private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
533583
{
534584
if (ProtocolTool.OutputSchema is null)

tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs

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

Comments
 (0)