From 519e96ce172906db49bd35622afbb4f9f929fd6f Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 13 Oct 2026 23:15:52 +0800 Subject: [PATCH 01/30] Fix nullable structured tool results --- .../Server/AIFunctionMcpServerTool.cs | 13 +++- .../Server/McpServerToolTests.cs | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 82b6ceb9d..e8477854e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -14,6 +15,7 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; + private readonly JsonSerializerOptions _structuredContentSerializerOptions; /// /// Creates an instance for a method, specified via a instance. @@ -225,6 +227,13 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider ProtocolTool = tool; _metadata = metadata; + _structuredContentSerializerOptions = tool.OutputSchema is not null && + function.JsonSerializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull + ? new JsonSerializerOptions(function.JsonSerializerOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + } + : function.JsonSerializerOptions; } /// @@ -677,7 +686,7 @@ private static void RewriteRefPointers(JsonNode? node) JsonElement jsonElement => jsonElement, JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), + _ => JsonSerializer.SerializeToElement(aiFunctionResult, _structuredContentSerializerOptions.GetTypeInfo(typeof(object))), }; // Pre-SEP-2106 clients expect the {"result": } envelope for non-object @@ -805,4 +814,4 @@ private static bool IsPrimitiveHeaderType(Type type) type == typeof(uint) || type == typeof(long); } -} \ No newline at end of file +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 8fd1d9954..2a7a9af55 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -481,6 +481,58 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); } + [Theory] + [InlineData(null, Sep2106ProtocolVersion)] + [InlineData("note", Sep2106ProtocolVersion)] + [InlineData(null, "2025-11-25")] + [InlineData("note", "2025-11-25")] + public async Task StructuredOutput_Enabled_RequiredNullableProperties_MatchesOutputSchema(string? note, string protocolVersion) + { + JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + McpJsonUtilities.DefaultOptions.TypeInfoResolver, + new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RequiredNullableOutput("42", note), + new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.NotNull(result.StructuredContent); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + } + + [Theory] + [InlineData(null, Sep2106ProtocolVersion)] + [InlineData("note", Sep2106ProtocolVersion)] + [InlineData(null, "2025-11-25")] + [InlineData("note", "2025-11-25")] + public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_MatchesOutputSchema(string? note, string protocolVersion) + { + JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + McpJsonUtilities.DefaultOptions.TypeInfoResolver, + new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RequiredNullablePropertiesOutput { Id = "42", Note = note }, + new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.NotNull(result.StructuredContent); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1091,6 +1143,15 @@ Instance JSON document does not match the specified schema. record Person(string Name, int Age); + private sealed record RequiredNullableOutput(string Id, string? Note); + + private sealed class RequiredNullablePropertiesOutput + { + public required string Id { get; init; } + + public required string? Note { get; init; } + } + private static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); From de107a5375bc8d910fbe78700778ecf0e9def962 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 20 Oct 2026 07:33:04 +0800 Subject: [PATCH 02/30] Fix structured output for required nullable properties Signed-off-by: mikemikimike <13286568797@163.com> --- .../Server/AIFunctionMcpServerTool.cs | 118 ++++++++++++++++-- .../Server/McpServerToolTests.cs | 64 ++++++++++ 2 files changed, 172 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index e8477854e..4bef486af 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -15,7 +14,6 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; - private readonly JsonSerializerOptions _structuredContentSerializerOptions; /// /// Creates an instance for a method, specified via a instance. @@ -227,13 +225,6 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider ProtocolTool = tool; _metadata = metadata; - _structuredContentSerializerOptions = tool.OutputSchema is not null && - function.JsonSerializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull - ? new JsonSerializerOptions(function.JsonSerializerOptions) - { - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - } - : function.JsonSerializerOptions; } /// @@ -686,9 +677,11 @@ private static void RewriteRefPointers(JsonNode? node) JsonElement jsonElement => jsonElement, JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, _structuredContentSerializerOptions.GetTypeInfo(typeof(object))), + _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; + elementResult = AddMissingRequiredNullableProperties(elementResult); + // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided // fresh per request from the stored natural schema. @@ -707,6 +700,111 @@ private static void RewriteRefPointers(JsonNode? node) return elementResult; } + private JsonElement? AddMissingRequiredNullableProperties(JsonElement? elementResult) + { + if (elementResult is not { } result || + ProtocolTool.OutputSchema is not { } outputSchema || + result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) + { + return elementResult; + } + + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + if (resultNode is null || schemaNode is null || !AddMissingRequiredNullableProperties(resultNode, schemaNode)) + { + return elementResult; + } + + return JsonSerializer.SerializeToElement(resultNode, McpJsonUtilities.JsonContext.Default.JsonNode); + } + + private static bool AddMissingRequiredNullableProperties(JsonNode resultNode, JsonNode schemaNode) + { + bool changed = false; + + if (resultNode is JsonObject resultObject && schemaNode is JsonObject schemaObject) + { + JsonArray? required = schemaObject["required"] as JsonArray; + JsonObject? properties = schemaObject["properties"] as JsonObject; + + if (properties is not null) + { + foreach (KeyValuePair property in resultObject) + { + if (property.Value is not null && properties.TryGetPropertyValue(property.Key, out JsonNode? propertySchema)) + { + changed |= AddMissingRequiredNullableProperties(property.Value, propertySchema!); + } + } + } + + if (required is not null && properties is not null) + { + foreach (JsonNode? requiredProperty in required) + { + string? propertyName = requiredProperty?.GetValue(); + if (propertyName is not null && + !resultObject.ContainsKey(propertyName) && + properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) && + SchemaAllowsNull(propertySchema)) + { + resultObject[propertyName] = null; + changed = true; + } + } + } + } + else if (resultNode is JsonArray resultArray && + schemaNode is JsonObject { } arraySchema && + arraySchema["items"] is JsonNode itemSchema) + { + foreach (JsonNode? item in resultArray) + { + if (item is not null) + { + changed |= AddMissingRequiredNullableProperties(item, itemSchema); + } + } + } + + return changed; + } + + private static bool SchemaAllowsNull(JsonNode? schemaNode) + { + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out bool allowsNull)) + { + return allowsNull; + } + + if (schemaNode is not JsonObject schemaObject) + { + return false; + } + + if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type)) + { + return type == "null"; + } + + if (schemaObject["type"] is JsonArray types && + types.Any(type => type is JsonValue value && value.TryGetValue(out string? typeName) && typeName == "null")) + { + return true; + } + + foreach (string keyword in new[] { "anyOf", "oneOf" }) + { + if (schemaObject[keyword] is JsonArray alternatives && alternatives.Any(SchemaAllowsNull)) + { + return true; + } + } + + return false; + } + private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) { List contentList = []; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2a7a9af55..cc05d1b16 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -533,6 +533,53 @@ public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_Mat AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_OmitsOptionalNullProperty() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"note":{"type":"string"}},"required":["id"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task StructuredOutput_Enabled_ExplicitSchema_EmitsRequiredNullableValueWithIgnorePolicies(string? note) + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"defaultedNote":{"type":["string","null"]},"ignoredNote":{"type":["string","null"]}},"required":["id","defaultedNote","ignoredNote"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberIgnore { Id = "42", DefaultedNote = note, IgnoredNote = note }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + foreach (string propertyName in new[] { "defaultedNote", "ignoredNote" }) + { + JsonElement noteElement = result.StructuredContent.Value.GetProperty(propertyName); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, noteElement.ValueKind); + Assert.Equal(note, noteElement.GetString()); + } + + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1152,6 +1199,23 @@ private sealed class RequiredNullablePropertiesOutput public required string? Note { get; init; } } + private sealed class ExplicitSchemaOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class ExplicitSchemaOutputWithMemberIgnore + { + public required string Id { get; init; } + + public string? DefaultedNote { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IgnoredNote { get; init; } + } + private static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); From 55b7972abd73edb4786f6220862d8644defd0474 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 21 Oct 2026 19:54:01 +0800 Subject: [PATCH 03/30] Preserve schema-required nullable output members Signed-off-by: mikemikimike <13286568797@163.com> --- .../Server/AIFunctionMcpServerTool.cs | 532 ++++++++++++++++-- .../Server/McpServerToolTests.cs | 152 +++++ 2 files changed, 648 insertions(+), 36 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 4bef486af..75fbd8531 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,6 +6,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -672,15 +674,41 @@ private static void RewriteRefPointers(JsonNode? node) return null; } - JsonElement? elementResult = aiFunctionResult switch + JsonElement? elementResult; + JsonElement? elementResultIncludingDefaults; + switch (aiFunctionResult) { - JsonElement jsonElement => jsonElement, - JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), - null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), - }; + case JsonElement jsonElement: + elementResult = elementResultIncludingDefaults = jsonElement; + break; + case JsonNode node: + elementResult = elementResultIncludingDefaults = JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode); + break; + case null: + elementResult = elementResultIncludingDefaults = null; + break; + default: + JsonSerializerOptions serializerOptions = AIFunction.JsonSerializerOptions; + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(typeof(object)); + elementResult = JsonSerializer.SerializeToElement(aiFunctionResult, typeInfo); + + if (serializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.Never) + { + elementResultIncludingDefaults = elementResult; + } + else + { + JsonSerializerOptions includeDefaultsOptions = new(serializerOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + elementResultIncludingDefaults = JsonSerializer.SerializeToElement( + aiFunctionResult, includeDefaultsOptions.GetTypeInfo(typeof(object))); + } + break; + } - elementResult = AddMissingRequiredNullableProperties(elementResult); + elementResult = AddMissingRequiredNullableProperties(elementResult, elementResultIncludingDefaults); // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided @@ -700,7 +728,7 @@ private static void RewriteRefPointers(JsonNode? node) return elementResult; } - private JsonElement? AddMissingRequiredNullableProperties(JsonElement? elementResult) + private JsonElement? AddMissingRequiredNullableProperties(JsonElement? elementResult, JsonElement? elementResultIncludingDefaults) { if (elementResult is not { } result || ProtocolTool.OutputSchema is not { } outputSchema || @@ -710,8 +738,16 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonNode? resultNodeIncludingDefaults = elementResultIncludingDefaults is { } completeResult + ? JsonSerializer.SerializeToNode(completeResult, McpJsonUtilities.JsonContext.Default.JsonElement) + : null; JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - if (resultNode is null || schemaNode is null || !AddMissingRequiredNullableProperties(resultNode, schemaNode)) + if (resultNode is null || schemaNode is null || !AddMissingRequiredNullableProperties( + resultNode, + resultNodeIncludingDefaults, + schemaNode, + schemaNode, + new HashSet(StringComparer.Ordinal))) { return elementResult; } @@ -719,35 +755,101 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return JsonSerializer.SerializeToElement(resultNode, McpJsonUtilities.JsonContext.Default.JsonNode); } - private static bool AddMissingRequiredNullableProperties(JsonNode resultNode, JsonNode schemaNode) + private static bool AddMissingRequiredNullableProperties( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + JsonNode? schemaNode, + JsonNode schemaRoot, + HashSet activeReferences) { + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) + { + return false; + } + + if (schemaNode is not JsonObject schemaObject) + { + return false; + } + bool changed = false; + if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && + refText is not null && activeReferences.Add(refText)) + { + if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + { + changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, referencedSchema, schemaRoot, activeReferences); + } + activeReferences.Remove(refText); + } - if (resultNode is JsonObject resultObject && schemaNode is JsonObject schemaObject) + if (resultNode is JsonObject resultObject) { - JsonArray? required = schemaObject["required"] as JsonArray; + JsonObject? resultObjectIncludingDefaults = resultNodeIncludingDefaults as JsonObject; JsonObject? properties = schemaObject["properties"] as JsonObject; - - if (properties is not null) + JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject; + foreach (string propertyName in resultObject.Select(static property => property.Key).ToArray()) { - foreach (KeyValuePair property in resultObject) + if (resultObject[propertyName] is not { } propertyValue) { - if (property.Value is not null && properties.TryGetPropertyValue(property.Key, out JsonNode? propertySchema)) + continue; + } + + List propertySchemas = []; + JsonNode? propertySchema = null; + bool matchedPropertySchema = properties?.TryGetPropertyValue(propertyName, out propertySchema) is true; + if (matchedPropertySchema && propertySchema is not null) + { + propertySchemas.Add(propertySchema); + } + + if (patternProperties is not null) + { + foreach (KeyValuePair patternProperty in patternProperties) { - changed |= AddMissingRequiredNullableProperties(property.Value, propertySchema!); + if (patternProperty.Value is not null && Regex.IsMatch(propertyName, patternProperty.Key) && + !propertySchemas.Contains(patternProperty.Value)) + { + propertySchemas.Add(patternProperty.Value); + matchedPropertySchema = true; + } } } + + if (!matchedPropertySchema && schemaObject["additionalProperties"] is JsonNode additionalProperties) + { + propertySchemas.Add(additionalProperties); + } + + JsonNode? completePropertyValue = null; + resultObjectIncludingDefaults?.TryGetPropertyValue(propertyName, out completePropertyValue); + foreach (JsonNode childSchema in propertySchemas) + { + changed |= AddMissingRequiredNullableProperties(propertyValue, completePropertyValue, childSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); + } } - if (required is not null && properties is not null) + if (schemaObject["required"] is JsonArray required && properties is not null) { foreach (JsonNode? requiredProperty in required) { string? propertyName = requiredProperty?.GetValue(); - if (propertyName is not null && - !resultObject.ContainsKey(propertyName) && - properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) && - SchemaAllowsNull(propertySchema)) + if (propertyName is null || resultObject.ContainsKey(propertyName) || + !properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema)) + { + continue; + } + + bool allowsNull = SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal)); + if (resultObjectIncludingDefaults?.TryGetPropertyValue(propertyName, out JsonNode? completePropertyValue) is true) + { + if (completePropertyValue is not null || allowsNull) + { + resultObject[propertyName] = completePropertyValue?.DeepClone(); + changed = true; + } + } + else if (allowsNull) { resultObject[propertyName] = null; changed = true; @@ -755,24 +857,289 @@ private static bool AddMissingRequiredNullableProperties(JsonNode resultNode, Js } } } - else if (resultNode is JsonArray resultArray && - schemaNode is JsonObject { } arraySchema && - arraySchema["items"] is JsonNode itemSchema) + + if (resultNode is JsonArray resultArray) + { + JsonArray? resultArrayIncludingDefaults = resultNodeIncludingDefaults as JsonArray; + JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; + JsonNode? items = schemaObject["items"]; + for (int i = 0; i < resultArray.Count; i++) + { + JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; + if (resultArray[i] is { } item && itemSchema is not null) + { + JsonNode? completeItem = resultArrayIncludingDefaults is not null && i < resultArrayIncludingDefaults.Count + ? resultArrayIncludingDefaults[i] + : null; + changed |= AddMissingRequiredNullableProperties(item, completeItem, itemSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); + } + } + } + + if (schemaObject["allOf"] is JsonArray allOf) { - foreach (JsonNode? item in resultArray) + foreach (JsonNode? branch in allOf) { - if (item is not null) + if (branch is not null) { - changed |= AddMissingRequiredNullableProperties(item, itemSchema); + changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); } } } + if (schemaObject["anyOf"] is JsonArray anyOf) + { + changed |= ApplyApplicableCompositionBranch(resultNode, resultNodeIncludingDefaults, anyOf, schemaRoot, exactlyOne: false); + } + + if (schemaObject["oneOf"] is JsonArray oneOf) + { + changed |= ApplyApplicableCompositionBranch(resultNode, resultNodeIncludingDefaults, oneOf, schemaRoot, exactlyOne: true); + } + return changed; } - private static bool SchemaAllowsNull(JsonNode? schemaNode) + private static bool ApplyApplicableCompositionBranch( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + JsonArray branches, + JsonNode schemaRoot, + bool exactlyOne) + { + List applicableBranches = []; + foreach (JsonNode? branch in branches) + { + if (branch is not null && SchemaCouldMatch(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + applicableBranches.Add(branch); + } + } + + if (applicableBranches.Count == 0 || (exactlyOne && applicableBranches.Count != 1)) + { + return false; + } + + IEnumerable branchesToApply = exactlyOne + ? applicableBranches + : applicableBranches.OrderBy(branch => CountMissingNullableRequirements(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal))).Take(1); + bool changed = false; + foreach (JsonNode branch in branchesToApply) + { + changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); + } + + return changed; + } + + private static int CountMissingNullableRequirements(JsonNode resultNode, JsonNode schemaNode, JsonNode schemaRoot, HashSet activeReferences) + { + if (schemaNode is not JsonObject schemaObject) + { + return 0; + } + + int count = 0; + if (resultNode is JsonObject resultObject && schemaObject["required"] is JsonArray required && schemaObject["properties"] is JsonObject properties) + { + foreach (JsonNode? requiredProperty in required) + { + string? propertyName = requiredProperty?.GetValue(); + if (propertyName is not null && !resultObject.ContainsKey(propertyName) && + properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) && + SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + count++; + } + } + } + + if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && + refText is not null && activeReferences.Add(refText) && TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + { + count += CountMissingNullableRequirements(resultNode, referencedSchema!, schemaRoot, activeReferences); + activeReferences.Remove(refText); + } + + if (schemaObject["allOf"] is JsonArray allOf) + { + foreach (JsonNode? branch in allOf) + { + if (branch is not null) + { + count += CountMissingNullableRequirements(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); + } + } + } + + return count; + } + + private static bool SchemaCouldMatch(JsonNode? instance, JsonNode? schemaNode, JsonNode schemaRoot, HashSet activeReferences) + { + if (schemaNode is JsonValue booleanSchema && booleanSchema.TryGetValue(out bool isAllowed)) + { + return isAllowed; + } + + if (schemaNode is not JsonObject schemaObject) + { + return true; + } + + if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && + refText is not null && activeReferences.Add(refText)) + { + if (!TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema) || + !SchemaCouldMatch(instance, referencedSchema, schemaRoot, activeReferences)) + { + activeReferences.Remove(refText); + return false; + } + activeReferences.Remove(refText); + } + + if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type) && + !ValueMatchesType(instance, type)) + { + return false; + } + + if (schemaObject["type"] is JsonArray types && !types.Any(typeNode => + typeNode is JsonValue typeName && typeName.TryGetValue(out string? name) && ValueMatchesType(instance, name))) + { + return false; + } + + if (schemaObject.TryGetPropertyValue("const", out JsonNode? constValue) && !JsonNodesEqual(instance, constValue)) + { + return false; + } + + if (schemaObject["enum"] is JsonArray enumValues && !enumValues.Any(value => JsonNodesEqual(instance, value))) + { + return false; + } + + if (instance is JsonObject instanceObject) + { + if (schemaObject["required"] is JsonArray required && schemaObject["properties"] is JsonObject properties) + { + foreach (JsonNode? requiredProperty in required) + { + string? propertyName = requiredProperty?.GetValue(); + if (propertyName is not null && !instanceObject.ContainsKey(propertyName) && + (!properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) || + !SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal)))) + { + return false; + } + } + } + + JsonObject? propertiesForInstance = schemaObject["properties"] as JsonObject; + JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject; + foreach (KeyValuePair property in instanceObject) + { + bool matched = false; + if (propertiesForInstance?.TryGetPropertyValue(property.Key, out JsonNode? propertySchema) is true) + { + matched = true; + if (!SchemaCouldMatch(property.Value, propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + return false; + } + } + + if (patternProperties is not null) + { + foreach (KeyValuePair patternProperty in patternProperties) + { + if (Regex.IsMatch(property.Key, patternProperty.Key)) + { + matched = true; + if (!SchemaCouldMatch(property.Value, patternProperty.Value, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + return false; + } + } + } + } + + if (!matched && schemaObject["additionalProperties"] is JsonValue additionalBoolean && + additionalBoolean.TryGetValue(out bool allowsAdditionalProperties) && !allowsAdditionalProperties) + { + return false; + } + } + } + + if (instance is JsonArray instanceArray) + { + JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; + JsonNode? items = schemaObject["items"]; + for (int i = 0; i < instanceArray.Count; i++) + { + JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; + if (itemSchema is not null && !SchemaCouldMatch(instanceArray[i], itemSchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + return false; + } + } + } + + if (schemaObject["allOf"] is JsonArray allOf && + allOf.Any(branch => !SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + { + return false; + } + + if (schemaObject["anyOf"] is JsonArray anyOf && + !anyOf.Any(branch => SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + { + return false; + } + + if (schemaObject["oneOf"] is JsonArray oneOf && + oneOf.Count(branch => SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal))) != 1) + { + return false; + } + + return true; + } + + private static bool ValueMatchesType(JsonNode? value, string? type) => type switch { + "null" => value is null, + "object" => value is JsonObject, + "array" => value is JsonArray, + "string" => value is JsonValue jsonValue && jsonValue.TryGetValue(out _), + "boolean" => value is JsonValue jsonValue && jsonValue.TryGetValue(out _), + "integer" => value is JsonValue jsonValue && jsonValue.TryGetValue(out JsonElement element) && element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out _), + "number" => value is JsonValue jsonValue && jsonValue.TryGetValue(out JsonElement element) && element.ValueKind == JsonValueKind.Number, + _ => true, + }; + + private static bool JsonNodesEqual(JsonNode? left, JsonNode? right) + { + if (left is null || right is null) + { + return left is null && right is null; + } + + return JsonSerializer.SerializeToElement(left, McpJsonUtilities.JsonContext.Default.JsonNode).GetRawText() == + JsonSerializer.SerializeToElement(right, McpJsonUtilities.JsonContext.Default.JsonNode).GetRawText(); + } + + private static bool SchemaAllowsNull(JsonNode? schemaNode, JsonNode schemaRoot, HashSet activeReferences) + { + if (schemaNode is null) + { + return true; + } + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out bool allowsNull)) { return allowsNull; @@ -783,26 +1150,119 @@ private static bool SchemaAllowsNull(JsonNode? schemaNode) return false; } - if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type)) + if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && refText is not null) { - return type == "null"; + if (!activeReferences.Add(refText)) + { + return true; + } + + bool referenceAllowsNull = TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema) && + SchemaAllowsNull(referencedSchema, schemaRoot, activeReferences); + activeReferences.Remove(refText); + if (!referenceAllowsNull) + { + return false; + } + } + + if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type) && type != "null") + { + return false; } if (schemaObject["type"] is JsonArray types && - types.Any(type => type is JsonValue value && value.TryGetValue(out string? typeName) && typeName == "null")) + !types.Any(typeNode => typeNode is JsonValue value && value.TryGetValue(out string? name) && name == "null")) + { + return false; + } + + if (schemaObject.TryGetPropertyValue("const", out JsonNode? constValue) && constValue is not null) + { + return false; + } + + if (schemaObject["enum"] is JsonArray enumValues && !enumValues.Any(static value => value is null)) + { + return false; + } + + if (schemaObject["allOf"] is JsonArray allOf && + allOf.Any(branch => !SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + { + return false; + } + + if (schemaObject["anyOf"] is JsonArray anyOf && + !anyOf.Any(branch => SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + { + return false; + } + + if (schemaObject["oneOf"] is JsonArray oneOf && + oneOf.Count(branch => SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal))) != 1) { + return false; + } + + if (schemaObject["not"] is JsonNode notSchema && SchemaAllowsNull(notSchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + return false; + } + + if (schemaObject["if"] is JsonNode ifSchema) + { + bool ifMatchesNull = SchemaCouldMatch(null, ifSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); + JsonNode? conditionalBranch = ifMatchesNull ? schemaObject["then"] : schemaObject["else"]; + if (conditionalBranch is not null && !SchemaAllowsNull(conditionalBranch, schemaRoot, new HashSet(StringComparer.Ordinal))) + { + return false; + } + } + + return true; + } + + private static bool TryResolveLocalReference(JsonNode schemaRoot, string reference, out JsonNode? resolvedSchema) + { + resolvedSchema = null; + if (!reference.StartsWith('#')) + { + return false; + } + + string pointer = Uri.UnescapeDataString(reference[1..]); + if (pointer.Length == 0) + { + resolvedSchema = schemaRoot; return true; } - foreach (string keyword in new[] { "anyOf", "oneOf" }) + if (!pointer.StartsWith('/')) { - if (schemaObject[keyword] is JsonArray alternatives && alternatives.Any(SchemaAllowsNull)) + return false; + } + + JsonNode? current = schemaRoot; + foreach (string rawSegment in pointer[1..].Split('/')) + { + string segment = rawSegment.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal); + if (current is JsonObject currentObject && currentObject.TryGetPropertyValue(segment, out JsonNode? child)) { - return true; + current = child; + } + else if (current is JsonArray currentArray && int.TryParse(segment, out int index) && index >= 0 && index < currentArray.Count) + { + current = currentArray[index]; + } + else + { + return false; } } - return false; + resolvedSchema = current; + return true; } private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index cc05d1b16..21496ce93 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -580,6 +580,151 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_EmitsRequiredNullableV AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesRequiredDefaultValue() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithDefaultValue { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { + "entry": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] } + }, + "required": ["id", "note"] + } + }, + "type": "object", + "additionalProperties": { "$ref": "#/$defs/entry" } + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new Dictionary { ["entry"] = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("entry").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesAllOfRequirements() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" } }, + "allOf": [ + { + "properties": { "note": { "type": ["string", "null"] } }, + "required": ["note"] + } + ], + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPrefixItemInsteadOfTrailingItemsSchema() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + } + ], + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] } + }, + "required": ["id", "note"] + } + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new[] { new ExplicitSchemaOutput { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value[0].TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{}},\"required\":[\"id\",\"note\"]}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{\"const\":null}},\"required\":[\"id\",\"note\"]}")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecognizesPermissiveNullSchemas(string schema) + { + JsonElement outputSchema = JsonDocument.Parse(schema).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1216,6 +1361,13 @@ private sealed class ExplicitSchemaOutputWithMemberIgnore public string? IgnoredNote { get; init; } } + private sealed class ExplicitSchemaOutputWithDefaultValue + { + public required string Id { get; init; } + + public int Count { get; init; } + } + private static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); From f1a39f86ccd4b6abdfba7ab3121444b211794b91 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 22 Oct 2026 15:31:42 +0800 Subject: [PATCH 04/30] Fix structured output required member restoration --- .../ModelContextProtocol.Core.csproj | 1 + .../Server/AIFunctionMcpServerTool.cs | 872 ++++++++++++------ .../Server/McpServerToolTests.cs | 318 +++++++ 3 files changed, 899 insertions(+), 292 deletions(-) diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 3fbef0377..20bacdddc 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -62,6 +62,7 @@ + diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 75fbd8531..dc710d015 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.AI; +using Json.Schema; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using System.Collections; using System.ComponentModel; +using System.Globalization; using System.Diagnostics; using System.Reflection; using System.Text.Json; @@ -16,6 +19,7 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; + private readonly Lazy _outputSchema; /// /// Creates an instance for a method, specified via a instance. @@ -225,6 +229,7 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider AIFunction = function; ProtocolTool = tool; + _outputSchema = new Lazy(BuildOutputSchema, LazyThreadSafetyMode.ExecutionAndPublication); _metadata = metadata; } @@ -708,7 +713,7 @@ private static void RewriteRefPointers(JsonNode? node) break; } - elementResult = AddMissingRequiredNullableProperties(elementResult, elementResultIncludingDefaults); + elementResult = AddMissingRequiredNullableProperties(elementResult, elementResultIncludingDefaults, aiFunctionResult); // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided @@ -728,7 +733,10 @@ private static void RewriteRefPointers(JsonNode? node) return elementResult; } - private JsonElement? AddMissingRequiredNullableProperties(JsonElement? elementResult, JsonElement? elementResultIncludingDefaults) + private JsonElement? AddMissingRequiredNullableProperties( + JsonElement? elementResult, + JsonElement? elementResultIncludingDefaults, + object? originalResult) { if (elementResult is not { } result || ProtocolTool.OutputSchema is not { } outputSchema || @@ -742,491 +750,771 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) ? JsonSerializer.SerializeToNode(completeResult, McpJsonUtilities.JsonContext.Default.JsonElement) : null; JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - if (resultNode is null || schemaNode is null || !AddMissingRequiredNullableProperties( - resultNode, - resultNodeIncludingDefaults, - schemaNode, - schemaNode, - new HashSet(StringComparer.Ordinal))) + JsonSchema? schema = _outputSchema.Value; + if (resultNode is null || schemaNode is null || schema is null) { return elementResult; } - return JsonSerializer.SerializeToElement(resultNode, McpJsonUtilities.JsonContext.Default.JsonNode); + JsonNode? repairedResult = RepairStructuredResult( + resultNode, + resultNodeIncludingDefaults, + schemaNode, + schema, + originalResult, + AIFunction.JsonSerializerOptions); + + return repairedResult is null + ? elementResult + : JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); } - private static bool AddMissingRequiredNullableProperties( + /// + /// Restores omitted required properties only when the complete result validates against the + /// published schema. The serializer can omit a value because of either the global ignore + /// policy or a member-level . Candidate values are therefore + /// read from the source object when the second serialization snapshot does not contain them. + /// + private static JsonNode? RepairStructuredResult( JsonNode resultNode, JsonNode? resultNodeIncludingDefaults, - JsonNode? schemaNode, - JsonNode schemaRoot, - HashSet activeReferences) + JsonNode schemaNode, + JsonSchema schema, + object? originalResult, + JsonSerializerOptions serializerOptions) { - if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) + // An explicit schema may deliberately omit optional nulls. Preserve that valid output + // before looking for missing required members. + if (IsSchemaValid(schema, resultNode)) + { + return null; + } + + JsonNode workingResult = resultNode.DeepClone(); + for (int pass = 0; pass < 32; pass++) + { + Dictionary contexts = new(StringComparer.Ordinal); + CollectSchemaContexts( + workingResult, + schemaNode, + schemaNode, + string.Empty, + contexts, + new HashSet(StringComparer.Ordinal)); + + List candidates = CreateRepairCandidates( + workingResult, + resultNodeIncludingDefaults, + originalResult, + serializerOptions, + contexts); + + if (candidates.Count == 0 || !TryFindValidCandidateSet(schema, workingResult, candidates, out JsonNode? repaired)) + { + return null; + } + + if (repaired is null) + { + return null; + } + + workingResult = repaired; + if (IsSchemaValid(schema, workingResult)) + { + return workingResult; + } + } + + return null; + } + + private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? schema) + { + try + { + JsonElement schemaElement = JsonSerializer.SerializeToElement(schemaNode, McpJsonUtilities.JsonContext.Default.JsonNode); + schema = JsonSchema.Build(schemaElement); + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) { + schema = null; return false; } + } - if (schemaNode is not JsonObject schemaObject) + private JsonSchema? BuildOutputSchema() + { + if (ProtocolTool.OutputSchema is not { } outputSchema) + { + return null; + } + + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + return schemaNode is not null && TryBuildJsonSchema(schemaNode, out JsonSchema? schema) + ? schema + : null; + } + + private static bool IsSchemaValid(JsonSchema schema, JsonNode instance) + { + try + { + JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + return schema.Evaluate(instanceElement).IsValid; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) { return false; } + } - bool changed = false; - if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && - refText is not null && activeReferences.Add(refText)) + private static List CreateRepairCandidates( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + object? originalResult, + JsonSerializerOptions serializerOptions, + Dictionary contexts) + { + List candidates = []; + HashSet seen = new(StringComparer.Ordinal); + + foreach (KeyValuePair contextEntry in contexts) { - if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + if (!TryGetNodeAtPath(resultNode, contextEntry.Key, out JsonNode? parentNode) || parentNode is not JsonObject parentObject) { - changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, referencedSchema, schemaRoot, activeReferences); + continue; } - activeReferences.Remove(refText); - } - if (resultNode is JsonObject resultObject) - { - JsonObject? resultObjectIncludingDefaults = resultNodeIncludingDefaults as JsonObject; - JsonObject? properties = schemaObject["properties"] as JsonObject; - JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject; - foreach (string propertyName in resultObject.Select(static property => property.Key).ToArray()) + foreach (string propertyName in contextEntry.Value.Required) { - if (resultObject[propertyName] is not { } propertyValue) + if (parentObject.ContainsKey(propertyName)) { continue; } - List propertySchemas = []; - JsonNode? propertySchema = null; - bool matchedPropertySchema = properties?.TryGetPropertyValue(propertyName, out propertySchema) is true; - if (matchedPropertySchema && propertySchema is not null) + string candidateKey = contextEntry.Key + "\u0000" + propertyName; + if (!seen.Add(candidateKey)) { - propertySchemas.Add(propertySchema); + continue; } - if (patternProperties is not null) + bool hasCompleteValue = TryGetNodeAtPath( + resultNodeIncludingDefaults, + AppendJsonPointer(contextEntry.Key, propertyName), + out JsonNode? completeValue); + + bool hasSourceValue = TryGetSourceValue( + originalResult, + contextEntry.Key, + propertyName, + serializerOptions, + out JsonNode? sourceValue); + + if (hasCompleteValue) { - foreach (KeyValuePair patternProperty in patternProperties) - { - if (patternProperty.Value is not null && Regex.IsMatch(propertyName, patternProperty.Key) && - !propertySchemas.Contains(patternProperty.Value)) - { - propertySchemas.Add(patternProperty.Value); - matchedPropertySchema = true; - } - } + candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, completeValue?.DeepClone())); } - - if (!matchedPropertySchema && schemaObject["additionalProperties"] is JsonNode additionalProperties) + else if (hasSourceValue) { - propertySchemas.Add(additionalProperties); + candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, sourceValue)); } - - JsonNode? completePropertyValue = null; - resultObjectIncludingDefaults?.TryGetPropertyValue(propertyName, out completePropertyValue); - foreach (JsonNode childSchema in propertySchemas) + else { - changed |= AddMissingRequiredNullableProperties(propertyValue, completePropertyValue, childSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); + candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, null)); } } + } - if (schemaObject["required"] is JsonArray required && properties is not null) - { - foreach (JsonNode? requiredProperty in required) - { - string? propertyName = requiredProperty?.GetValue(); - if (propertyName is null || resultObject.ContainsKey(propertyName) || - !properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema)) - { - continue; - } + return candidates; + } - bool allowsNull = SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal)); - if (resultObjectIncludingDefaults?.TryGetPropertyValue(propertyName, out JsonNode? completePropertyValue) is true) - { - if (completePropertyValue is not null || allowsNull) - { - resultObject[propertyName] = completePropertyValue?.DeepClone(); - changed = true; - } - } - else if (allowsNull) - { - resultObject[propertyName] = null; - changed = true; - } - } - } + private static bool TryFindValidCandidateSet( + JsonSchema schema, + JsonNode originalResult, + IReadOnlyList candidates, + out JsonNode? repairedResult) + { + repairedResult = null; + if (candidates.Count == 0) + { + return false; } - if (resultNode is JsonArray resultArray) + // Prefer larger repairs, but cap schema evaluations for unusually broad unions so a + // single tool call cannot trigger an exponential search. + const int maxCandidateSetEvaluations = 256; + bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); + int evaluations = 0; + JsonNode? matchingResult = null; + + bool TryCurrentSet() { - JsonArray? resultArrayIncludingDefaults = resultNodeIncludingDefaults as JsonArray; - JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; - JsonNode? items = schemaObject["items"]; - for (int i = 0; i < resultArray.Count; i++) + if (evaluations >= maxCandidateSetEvaluations) { - JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; - if (resultArray[i] is { } item && itemSchema is not null) - { - JsonNode? completeItem = resultArrayIncludingDefaults is not null && i < resultArrayIncludingDefaults.Count - ? resultArrayIncludingDefaults[i] - : null; - changed |= AddMissingRequiredNullableProperties(item, completeItem, itemSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); - } + return false; } - } - if (schemaObject["allOf"] is JsonArray allOf) - { - foreach (JsonNode? branch in allOf) + evaluations++; + JsonNode candidateResult = originalResult.DeepClone(); + bool hasIncludedCandidate = false; + for (int i = 0; i < candidates.Count; i++) { - if (branch is not null) + if (included[i]) { - changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); + ApplyRepairCandidate(candidateResult, candidates[i]); + hasIncludedCandidate = true; } } - } - if (schemaObject["anyOf"] is JsonArray anyOf) - { - changed |= ApplyApplicableCompositionBranch(resultNode, resultNodeIncludingDefaults, anyOf, schemaRoot, exactlyOne: false); - } + if (!hasIncludedCandidate || !IsSchemaValid(schema, candidateResult)) + { + return false; + } - if (schemaObject["oneOf"] is JsonArray oneOf) - { - changed |= ApplyApplicableCompositionBranch(resultNode, resultNodeIncludingDefaults, oneOf, schemaRoot, exactlyOne: true); + matchingResult = candidateResult; + return true; } - return changed; - } - - private static bool ApplyApplicableCompositionBranch( - JsonNode resultNode, - JsonNode? resultNodeIncludingDefaults, - JsonArray branches, - JsonNode schemaRoot, - bool exactlyOne) - { - List applicableBranches = []; - foreach (JsonNode? branch in branches) + bool SearchExclusions(int nextIndex, int remaining) { - if (branch is not null && SchemaCouldMatch(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal))) + if (remaining == 0) { - applicableBranches.Add(branch); + return TryCurrentSet(); + } + + for (int i = nextIndex; i <= candidates.Count - remaining; i++) + { + included[i] = false; + if (SearchExclusions(i + 1, remaining - 1)) + { + return true; + } + + included[i] = true; + if (evaluations >= maxCandidateSetEvaluations) + { + return false; + } } - } - if (applicableBranches.Count == 0 || (exactlyOne && applicableBranches.Count != 1)) - { return false; } - IEnumerable branchesToApply = exactlyOne - ? applicableBranches - : applicableBranches.OrderBy(branch => CountMissingNullableRequirements(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal))).Take(1); - bool changed = false; - foreach (JsonNode branch in branchesToApply) + if (TryCurrentSet()) { - changed |= AddMissingRequiredNullableProperties(resultNode, resultNodeIncludingDefaults, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); + repairedResult = matchingResult; + return true; } - return changed; - } - - private static int CountMissingNullableRequirements(JsonNode resultNode, JsonNode schemaNode, JsonNode schemaRoot, HashSet activeReferences) - { - if (schemaNode is not JsonObject schemaObject) + for (int excludedCount = 1; excludedCount < candidates.Count; excludedCount++) { - return 0; - } + if (SearchExclusions(0, excludedCount)) + { + repairedResult = matchingResult; + return true; + } - int count = 0; - if (resultNode is JsonObject resultObject && schemaObject["required"] is JsonArray required && schemaObject["properties"] is JsonObject properties) - { - foreach (JsonNode? requiredProperty in required) + if (evaluations >= maxCandidateSetEvaluations) { - string? propertyName = requiredProperty?.GetValue(); - if (propertyName is not null && !resultObject.ContainsKey(propertyName) && - properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) && - SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal))) - { - count++; - } + break; } } - if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && - refText is not null && activeReferences.Add(refText) && TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) - { - count += CountMissingNullableRequirements(resultNode, referencedSchema!, schemaRoot, activeReferences); - activeReferences.Remove(refText); - } + return false; + } - if (schemaObject["allOf"] is JsonArray allOf) + private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate) + { + if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject) { - foreach (JsonNode? branch in allOf) - { - if (branch is not null) - { - count += CountMissingNullableRequirements(resultNode, branch, schemaRoot, new HashSet(StringComparer.Ordinal)); - } - } + parentObject[candidate.PropertyName] = candidate.Value?.DeepClone(); } + } - return count; + private sealed class SchemaObjectContext + { + internal HashSet Required { get; } = new(StringComparer.Ordinal); } - private static bool SchemaCouldMatch(JsonNode? instance, JsonNode? schemaNode, JsonNode schemaRoot, HashSet activeReferences) + private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value); + + private static void CollectSchemaContexts( + JsonNode? instanceNode, + JsonNode? schemaNode, + JsonNode schemaRoot, + string instancePath, + Dictionary contexts, + HashSet activeReferences) { - if (schemaNode is JsonValue booleanSchema && booleanSchema.TryGetValue(out bool isAllowed)) + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) { - return isAllowed; + return; } if (schemaNode is not JsonObject schemaObject) { - return true; + return; } - if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && - refText is not null && activeReferences.Add(refText)) + if (schemaObject["$ref"] is JsonValue refValue && + refValue.TryGetValue(out string? refText) && + refText is not null && + activeReferences.Add(refText + "|" + instancePath)) { - if (!TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema) || - !SchemaCouldMatch(instance, referencedSchema, schemaRoot, activeReferences)) + if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) { - activeReferences.Remove(refText); - return false; + CollectSchemaContexts(instanceNode, referencedSchema, schemaRoot, instancePath, contexts, activeReferences); } - activeReferences.Remove(refText); - } - if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type) && - !ValueMatchesType(instance, type)) - { - return false; + activeReferences.Remove(refText + "|" + instancePath); } - if (schemaObject["type"] is JsonArray types && !types.Any(typeNode => - typeNode is JsonValue typeName && typeName.TryGetValue(out string? name) && ValueMatchesType(instance, name))) + SchemaObjectContext context = GetOrCreateSchemaContext(contexts, instancePath); + if (schemaObject["required"] is JsonArray required) { - return false; - } - - if (schemaObject.TryGetPropertyValue("const", out JsonNode? constValue) && !JsonNodesEqual(instance, constValue)) - { - return false; - } - - if (schemaObject["enum"] is JsonArray enumValues && !enumValues.Any(value => JsonNodesEqual(instance, value))) - { - return false; + foreach (JsonNode? requiredProperty in required) + { + if (requiredProperty?.GetValue() is string propertyName) + { + context.Required.Add(propertyName); + } + } } - if (instance is JsonObject instanceObject) + if (schemaObject["properties"] is JsonObject properties) { - if (schemaObject["required"] is JsonArray required && schemaObject["properties"] is JsonObject properties) + foreach (KeyValuePair property in properties) { - foreach (JsonNode? requiredProperty in required) + if (property.Value is null) { - string? propertyName = requiredProperty?.GetValue(); - if (propertyName is not null && !instanceObject.ContainsKey(propertyName) && - (!properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) || - !SchemaAllowsNull(propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal)))) - { - return false; - } + continue; + } + + if (instanceNode is not JsonObject instanceObject || + !instanceObject.TryGetPropertyValue(property.Key, out JsonNode? propertyInstance) || + propertyInstance is null) + { + continue; } + + CollectSchemaContexts( + propertyInstance, + property.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); } + } - JsonObject? propertiesForInstance = schemaObject["properties"] as JsonObject; + if (instanceNode is JsonObject objectInstance) + { JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject; - foreach (KeyValuePair property in instanceObject) + JsonNode? additionalProperties = schemaObject["additionalProperties"]; + foreach (KeyValuePair property in objectInstance) { - bool matched = false; - if (propertiesForInstance?.TryGetPropertyValue(property.Key, out JsonNode? propertySchema) is true) + if (schemaObject["properties"] is JsonObject explicitProperties && explicitProperties.ContainsKey(property.Key)) { - matched = true; - if (!SchemaCouldMatch(property.Value, propertySchema, schemaRoot, new HashSet(StringComparer.Ordinal))) - { - return false; - } + continue; } + bool matchedPattern = false; if (patternProperties is not null) { foreach (KeyValuePair patternProperty in patternProperties) { - if (Regex.IsMatch(property.Key, patternProperty.Key)) + if (patternProperty.Value is not null && Regex.IsMatch(property.Key, patternProperty.Key)) { - matched = true; - if (!SchemaCouldMatch(property.Value, patternProperty.Value, schemaRoot, new HashSet(StringComparer.Ordinal))) - { - return false; - } + matchedPattern = true; + CollectSchemaContexts( + property.Value, + patternProperty.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); } } } - if (!matched && schemaObject["additionalProperties"] is JsonValue additionalBoolean && - additionalBoolean.TryGetValue(out bool allowsAdditionalProperties) && !allowsAdditionalProperties) + if (!matchedPattern && additionalProperties is not null) { - return false; + CollectSchemaContexts( + property.Value, + additionalProperties, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); } } } - if (instance is JsonArray instanceArray) + if (instanceNode is JsonArray arrayInstance) { JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; JsonNode? items = schemaObject["items"]; - for (int i = 0; i < instanceArray.Count; i++) + for (int i = 0; i < arrayInstance.Count; i++) { JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; - if (itemSchema is not null && !SchemaCouldMatch(instanceArray[i], itemSchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + if (itemSchema is not null) { - return false; + CollectSchemaContexts( + arrayInstance[i], + itemSchema, + schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); } } } - if (schemaObject["allOf"] is JsonArray allOf && - allOf.Any(branch => !SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + foreach (string compositionKeyword in new[] { "allOf", "anyOf", "oneOf" }) { - return false; + if (schemaObject[compositionKeyword] is not JsonArray composition) + { + continue; + } + + foreach (JsonNode? branch in composition) + { + if (branch is not null) + { + CollectSchemaContexts( + instanceNode, + branch, + schemaRoot, + instancePath, + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } } - if (schemaObject["anyOf"] is JsonArray anyOf && - !anyOf.Any(branch => SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + if (schemaObject["if"] is JsonNode ifSchema) { - return false; + if (schemaObject["then"] is JsonNode thenSchema) + { + CollectSchemaContexts(instanceNode, thenSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + } + + if (schemaObject["else"] is JsonNode elseSchema) + { + CollectSchemaContexts(instanceNode, elseSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + } } + } - if (schemaObject["oneOf"] is JsonArray oneOf && - oneOf.Count(branch => SchemaCouldMatch(instance, branch, schemaRoot, new HashSet(StringComparer.Ordinal))) != 1) + private static SchemaObjectContext GetOrCreateSchemaContext(Dictionary contexts, string instancePath) + { + if (!contexts.TryGetValue(instancePath, out SchemaObjectContext? context)) { - return false; + contexts[instancePath] = context = new SchemaObjectContext(); } - return true; + return context; } - private static bool ValueMatchesType(JsonNode? value, string? type) => type switch + private static string AppendJsonPointer(string parentPath, string segment) { - "null" => value is null, - "object" => value is JsonObject, - "array" => value is JsonArray, - "string" => value is JsonValue jsonValue && jsonValue.TryGetValue(out _), - "boolean" => value is JsonValue jsonValue && jsonValue.TryGetValue(out _), - "integer" => value is JsonValue jsonValue && jsonValue.TryGetValue(out JsonElement element) && element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out _), - "number" => value is JsonValue jsonValue && jsonValue.TryGetValue(out JsonElement element) && element.ValueKind == JsonValueKind.Number, - _ => true, - }; - - private static bool JsonNodesEqual(JsonNode? left, JsonNode? right) + string escapedSegment = segment.Replace("~", "~0").Replace("/", "~1"); + return parentPath.Length == 0 ? "/" + escapedSegment : parentPath + "/" + escapedSegment; + } + + private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? value) { - if (left is null || right is null) + value = root; + if (path.Length == 0) { - return left is null && right is null; + return root is not null; } - return JsonSerializer.SerializeToElement(left, McpJsonUtilities.JsonContext.Default.JsonNode).GetRawText() == - JsonSerializer.SerializeToElement(right, McpJsonUtilities.JsonContext.Default.JsonNode).GetRawText(); + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + value = null; + return false; + } + + foreach (string rawSegment in path.Substring(1).Split('/')) + { + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + if (value is JsonObject objectValue && objectValue.TryGetPropertyValue(segment, out JsonNode? objectChild)) + { + value = objectChild; + } + else if (value is JsonArray arrayValue && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < arrayValue.Count) + { + value = arrayValue[index]; + } + else + { + value = null; + return false; + } + } + + return true; } - private static bool SchemaAllowsNull(JsonNode? schemaNode, JsonNode schemaRoot, HashSet activeReferences) + private static bool TryGetSourceValue( + object? sourceRoot, + string parentPath, + string propertyName, + JsonSerializerOptions serializerOptions, + out JsonNode? value) { - if (schemaNode is null) + value = null; + if (sourceRoot is null || !TryGetSourceObjectAtPath(sourceRoot, parentPath, serializerOptions, out object? sourceParent) || sourceParent is null) { - return true; + return false; } - if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out bool allowsNull)) + if (!TryGetSourceMember( + sourceParent, + propertyName, + serializerOptions, + out object? sourceValue, + out Type? declaredType, + out JsonPropertyInfo? propertyInfo)) { - return allowsNull; + return false; } - if (schemaNode is not JsonObject schemaObject) + value = SerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo); + return sourceValue is null || value is not null; + } + + private static bool TryGetSourceObjectAtPath( + object sourceRoot, + string path, + JsonSerializerOptions serializerOptions, + out object? value) + { + value = sourceRoot; + if (path.Length == 0) { + return true; + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + value = null; return false; } - if (schemaObject["$ref"] is JsonValue refValue && refValue.TryGetValue(out string? refText) && refText is not null) + foreach (string rawSegment in path.Substring(1).Split('/')) { - if (!activeReferences.Add(refText)) + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + if (value is null) { - return true; + return false; + } + + if (value is JsonNode jsonNode) + { + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(segment, out JsonNode? childNode)) + { + value = childNode; + continue; + } + + if (jsonNode is JsonArray jsonArray && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int jsonIndex) && + jsonIndex >= 0 && jsonIndex < jsonArray.Count) + { + value = jsonArray[jsonIndex]; + continue; + } + + value = null; + return false; } - bool referenceAllowsNull = TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema) && - SchemaAllowsNull(referencedSchema, schemaRoot, activeReferences); - activeReferences.Remove(refText); - if (!referenceAllowsNull) + if (value is JsonElement jsonElement) { + if (jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(segment, out JsonElement childElement)) + { + value = childElement; + continue; + } + + if (jsonElement.ValueKind == JsonValueKind.Array && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int elementIndex) && + elementIndex >= 0 && elementIndex < jsonElement.GetArrayLength()) + { + value = jsonElement[elementIndex]; + continue; + } + + value = null; return false; } + + if (value is IDictionary dictionary && dictionary.Contains(segment)) + { + value = dictionary[segment]; + continue; + } + + if (int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && value is IEnumerable enumerable) + { + int currentIndex = 0; + bool found = false; + foreach (object? item in enumerable) + { + if (currentIndex++ == index) + { + value = item; + found = true; + break; + } + } + + if (found) + { + continue; + } + } + + if (!TryGetSourceMember(value, segment, serializerOptions, out object? memberValue, out _, out _)) + { + value = null; + return false; + } + + value = memberValue; } - if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type) && type != "null") + return true; + } + + private static bool TryGetSourceMember( + object source, + string propertyName, + JsonSerializerOptions serializerOptions, + out object? value, + out Type? declaredType, + out JsonPropertyInfo? propertyInfo) + { + value = null; + declaredType = null; + propertyInfo = null; + + if (source is JsonNode jsonNode) { + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(propertyName, out JsonNode? childNode)) + { + value = childNode; + declaredType = typeof(JsonNode); + return true; + } + return false; } - if (schemaObject["type"] is JsonArray types && - !types.Any(typeNode => typeNode is JsonValue value && value.TryGetValue(out string? name) && name == "null")) + if (source is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(propertyName, out JsonElement childElement)) { - return false; + value = childElement; + declaredType = typeof(JsonElement); + return true; } - if (schemaObject.TryGetPropertyValue("const", out JsonNode? constValue) && constValue is not null) + if (source is IDictionary dictionary && dictionary.Contains(propertyName)) { - return false; + value = dictionary[propertyName]; + declaredType = value?.GetType(); + return true; } - if (schemaObject["enum"] is JsonArray enumValues && !enumValues.Any(static value => value is null)) + try { - return false; + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(source.GetType()); + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null) + { + value = property.Get(source); + declaredType = property.PropertyType; + propertyInfo = property; + return true; + } + } + } + catch (NotSupportedException) + { + // Without serializer metadata, keep the serialized snapshot as the source of truth. } - if (schemaObject["allOf"] is JsonArray allOf && - allOf.Any(branch => !SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + return false; + } + + private static JsonNode? SerializeSourceValue( + object? value, + Type? declaredType, + JsonSerializerOptions serializerOptions, + JsonPropertyInfo? propertyInfo) + { + if (value is JsonNode jsonNode) { - return false; + return jsonNode.DeepClone(); } - if (schemaObject["anyOf"] is JsonArray anyOf && - !anyOf.Any(branch => SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal)))) + if (value is JsonElement jsonElement) { - return false; + return JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement); } - if (schemaObject["oneOf"] is JsonArray oneOf && - oneOf.Count(branch => SchemaAllowsNull(branch, schemaRoot, new HashSet(StringComparer.Ordinal))) != 1) + Type valueType = declaredType ?? value?.GetType() ?? typeof(object); + try { - return false; + JsonSerializerOptions effectiveOptions = serializerOptions; + JsonConverter? propertyConverter = GetPropertyConverter(propertyInfo, valueType); + if (propertyConverter is not null) + { + effectiveOptions = new JsonSerializerOptions(serializerOptions); + effectiveOptions.Converters.Insert(0, propertyConverter); + } + + JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); + JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); + return JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); + } + catch (NotSupportedException) + { + return null; } + } - if (schemaObject["not"] is JsonNode notSchema && SchemaAllowsNull(notSchema, schemaRoot, new HashSet(StringComparer.Ordinal))) + private static JsonConverter? GetPropertyConverter(JsonPropertyInfo? propertyInfo, Type propertyType) + { + if (propertyInfo is null) { - return false; + return null; } - if (schemaObject["if"] is JsonNode ifSchema) + PropertyInfo? customConverterProperty = typeof(JsonPropertyInfo).GetProperty("CustomConverter", BindingFlags.Instance | BindingFlags.Public); + if (customConverterProperty?.GetValue(propertyInfo) is JsonConverter customConverter) { - bool ifMatchesNull = SchemaCouldMatch(null, ifSchema, schemaRoot, new HashSet(StringComparer.Ordinal)); - JsonNode? conditionalBranch = ifMatchesNull ? schemaObject["then"] : schemaObject["else"]; - if (conditionalBranch is not null && !SchemaAllowsNull(conditionalBranch, schemaRoot, new HashSet(StringComparer.Ordinal))) - { - return false; - } + return customConverter; } - return true; + if (propertyInfo.AttributeProvider is ICustomAttributeProvider attributeProvider && + attributeProvider.GetCustomAttributes(typeof(JsonConverterAttribute), inherit: true) + .OfType() + .FirstOrDefault() is { } converterAttribute) + { + return converterAttribute.CreateConverter(propertyType); + } + + return null; } private static bool TryResolveLocalReference(JsonNode schemaRoot, string reference, out JsonNode? resolvedSchema) { resolvedSchema = null; - if (!reference.StartsWith('#')) + if (!reference.StartsWith("#", StringComparison.Ordinal)) { return false; } @@ -1238,7 +1526,7 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen return true; } - if (!pointer.StartsWith('/')) + if (!pointer.StartsWith("/", StringComparison.Ordinal)) { return false; } @@ -1246,7 +1534,7 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen JsonNode? current = schemaRoot; foreach (string rawSegment in pointer[1..].Split('/')) { - string segment = rawSegment.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal); + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); if (current is JsonObject currentObject && currentObject.TryGetPropertyValue(segment, out JsonNode? child)) { current = child; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 21496ce93..eed1447e3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -600,6 +600,26 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesRequiredDefau AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesMemberConverterForRequiredDefault() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":"string"}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberConverter { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences() { @@ -634,6 +654,42 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryVal AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesRecursiveReferences() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { + "node": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] }, + "next": { "$ref": "#/$defs/node" } + }, + "required": ["id", "note"] + } + }, + "$ref": "#/$defs/node" + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RecursiveStructuredOutput { Id = "root", Note = null, Next = new() { Id = "child", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("next").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesAllOfRequirements() { @@ -725,6 +781,199 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RecognizesPermissiveNu AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesSplitRequiredAndPropertySchemas() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, + "allOf": [{ "required": ["note"] }], + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMemberLevelDefaultIgnore() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberDefault { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesAlreadyValidAnyOfBranch() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { + "properties": { + "count": { "type": "integer", "minimum": 1 }, + "child": { "type": "object", "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, "required": ["id", "note"] } + }, + "required": ["count", "child"] + }, + { + "properties": { "count": { "const": 0 }, "child": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"], "additionalProperties": false } }, + "required": ["count", "child"] + } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new AnyOfStructuredOutput { Count = 0, Child = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + Assert.False(result.StructuredContent.Value.GetProperty("child").TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsLargeIntegerBranch() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "anyOf": [ + { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] }, + { "type": "null" } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new LargeIntegerStructuredOutput { Value = ulong.MaxValue, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(ulong.MaxValue, result.StructuredContent.Value.GetProperty("value").GetUInt64()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralConstEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "tag": { "const": { "b": 2, "a": 1 } }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] }, + { "properties": { "tag": { "const": { "different": true } } }, "required": ["tag"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesJsonNumericEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "count": { "const": 1.0 }, "note": { "type": ["string", "null"] } }, "required": ["count", "note"] }, + { "properties": { "count": { "const": 2 } }, "required": ["count"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NumericConstOutput { Count = 1, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_HandlesNotConditionalForNull() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "if": { "not": { "type": "null" } }, "then": { "type": "string" }, "else": { "type": "null" } } }, + "required": ["id", "note"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1368,6 +1617,75 @@ private sealed class ExplicitSchemaOutputWithDefaultValue public int Count { get; init; } } + private sealed class ExplicitSchemaOutputWithMemberDefault + { + public required string Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + private sealed class ExplicitSchemaOutputWithMemberConverter + { + public required string Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(IntAsStringJsonConverter))] + public int Count { get; init; } + } + + private sealed class IntAsStringJsonConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private sealed class AnyOfStructuredOutput + { + public int Count { get; init; } + + public required AnyOfStructuredChild Child { get; init; } + } + + private sealed class AnyOfStructuredChild + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class LargeIntegerStructuredOutput + { + public ulong Value { get; init; } + + public string? Note { get; init; } + } + + private sealed class StructuralConstOutput + { + public required JsonObject Tag { get; init; } + + public string? Note { get; init; } + } + + private sealed class NumericConstOutput + { + public int Count { get; init; } + + public string? Note { get; init; } + } + + private sealed class RecursiveStructuredOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + + public RecursiveStructuredOutput? Next { get; init; } + } + private static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); From c4f9d7120b2ec6bd3094047776932ca43e7cdb9b Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 23 Oct 2026 10:00:51 +0800 Subject: [PATCH 05/30] Repair structured output against explicit schemas --- .../Server/AIFunctionMcpServerTool.cs | 277 +++++++++++++---- .../Server/McpServerToolTests.cs | 292 ++++++++++++++++++ 2 files changed, 514 insertions(+), 55 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index dc710d015..050dc3318 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -20,6 +20,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; private readonly Lazy _outputSchema; + private readonly Lazy _serializerTypeInfoIncludingDefaults; /// /// Creates an instance for a method, specified via a instance. @@ -230,6 +231,14 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider AIFunction = function; ProtocolTool = tool; _outputSchema = new Lazy(BuildOutputSchema, LazyThreadSafetyMode.ExecutionAndPublication); + _serializerTypeInfoIncludingDefaults = new Lazy(() => + { + JsonSerializerOptions options = new(AIFunction.JsonSerializerOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + return options.GetTypeInfo(typeof(object)); + }, LazyThreadSafetyMode.ExecutionAndPublication); _metadata = metadata; } @@ -680,40 +689,25 @@ private static void RewriteRefPointers(JsonNode? node) } JsonElement? elementResult; - JsonElement? elementResultIncludingDefaults; switch (aiFunctionResult) { case JsonElement jsonElement: - elementResult = elementResultIncludingDefaults = jsonElement; + elementResult = jsonElement; break; case JsonNode node: - elementResult = elementResultIncludingDefaults = JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode); + elementResult = JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode); break; case null: - elementResult = elementResultIncludingDefaults = null; + elementResult = null; break; default: JsonSerializerOptions serializerOptions = AIFunction.JsonSerializerOptions; JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(typeof(object)); elementResult = JsonSerializer.SerializeToElement(aiFunctionResult, typeInfo); - - if (serializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.Never) - { - elementResultIncludingDefaults = elementResult; - } - else - { - JsonSerializerOptions includeDefaultsOptions = new(serializerOptions) - { - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - }; - elementResultIncludingDefaults = JsonSerializer.SerializeToElement( - aiFunctionResult, includeDefaultsOptions.GetTypeInfo(typeof(object))); - } break; } - elementResult = AddMissingRequiredNullableProperties(elementResult, elementResultIncludingDefaults, aiFunctionResult); + elementResult = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult); // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided @@ -735,7 +729,6 @@ private static void RewriteRefPointers(JsonNode? node) private JsonElement? AddMissingRequiredNullableProperties( JsonElement? elementResult, - JsonElement? elementResultIncludingDefaults, object? originalResult) { if (elementResult is not { } result || @@ -746,9 +739,6 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); - JsonNode? resultNodeIncludingDefaults = elementResultIncludingDefaults is { } completeResult - ? JsonSerializer.SerializeToNode(completeResult, McpJsonUtilities.JsonContext.Default.JsonElement) - : null; JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); JsonSchema? schema = _outputSchema.Value; if (resultNode is null || schemaNode is null || schema is null) @@ -756,6 +746,21 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } + if (IsSchemaValid(schema, resultNode)) + { + return elementResult; + } + + JsonNode? resultNodeIncludingDefaults = resultNode; + if (originalResult is not null and not JsonElement and not JsonNode && + AIFunction.JsonSerializerOptions.DefaultIgnoreCondition is not JsonIgnoreCondition.Never) + { + JsonElement completeResult = JsonSerializer.SerializeToElement( + originalResult, _serializerTypeInfoIncludingDefaults.Value); + resultNodeIncludingDefaults = JsonSerializer.SerializeToNode( + completeResult, McpJsonUtilities.JsonContext.Default.JsonElement); + } + JsonNode? repairedResult = RepairStructuredResult( resultNode, resultNodeIncludingDefaults, @@ -809,7 +814,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) serializerOptions, contexts); - if (candidates.Count == 0 || !TryFindValidCandidateSet(schema, workingResult, candidates, out JsonNode? repaired)) + if (candidates.Count == 0 || !TryFindCandidateSet(schema, workingResult, candidates, out JsonNode? repaired)) { return null; } @@ -905,32 +910,26 @@ private static List CreateRepairCandidates( AppendJsonPointer(contextEntry.Key, propertyName), out JsonNode? completeValue); - bool hasSourceValue = TryGetSourceValue( - originalResult, - contextEntry.Key, - propertyName, - serializerOptions, - out JsonNode? sourceValue); - if (hasCompleteValue) { candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, completeValue?.DeepClone())); } - else if (hasSourceValue) + else if (TryGetSourceValue( + originalResult, + contextEntry.Key, + propertyName, + serializerOptions, + out JsonNode? sourceValue)) { candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, sourceValue)); } - else - { - candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, null)); - } } } return candidates; } - private static bool TryFindValidCandidateSet( + private static bool TryFindCandidateSet( JsonSchema schema, JsonNode originalResult, IReadOnlyList candidates, @@ -942,8 +941,8 @@ private static bool TryFindValidCandidateSet( return false; } - // Prefer larger repairs, but cap schema evaluations for unusually broad unions so a - // single tool call cannot trigger an exponential search. + // Prefer the full repair first, then small subsets so discriminated unions do not + // spend the evaluation budget excluding unrelated branch properties. const int maxCandidateSetEvaluations = 256; bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); int evaluations = 0; @@ -977,7 +976,7 @@ bool TryCurrentSet() return true; } - bool SearchExclusions(int nextIndex, int remaining) + bool SearchInclusions(int nextIndex, int remaining) { if (remaining == 0) { @@ -986,13 +985,13 @@ bool SearchExclusions(int nextIndex, int remaining) for (int i = nextIndex; i <= candidates.Count - remaining; i++) { - included[i] = false; - if (SearchExclusions(i + 1, remaining - 1)) + included[i] = true; + if (SearchInclusions(i + 1, remaining - 1)) { return true; } - included[i] = true; + included[i] = false; if (evaluations >= maxCandidateSetEvaluations) { return false; @@ -1008,9 +1007,14 @@ bool SearchExclusions(int nextIndex, int remaining) return true; } - for (int excludedCount = 1; excludedCount < candidates.Count; excludedCount++) + for (int i = 0; i < included.Length; i++) { - if (SearchExclusions(0, excludedCount)) + included[i] = false; + } + + for (int includedCount = 1; includedCount < candidates.Count; includedCount++) + { + if (SearchInclusions(0, includedCount)) { repairedResult = matchingResult; return true; @@ -1022,7 +1026,16 @@ bool SearchExclusions(int nextIndex, int remaining) } } - return false; + // A restored parent may expose required descendants on the next pass. Only + // source-backed candidates are created, and the caller publishes a result only + // after the complete schema validates. + repairedResult = originalResult.DeepClone(); + foreach (RepairCandidate candidate in candidates) + { + ApplyRepairCandidate(repairedResult, candidate); + } + + return true; } private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate) @@ -1115,10 +1128,8 @@ refText is not null && JsonNode? additionalProperties = schemaObject["additionalProperties"]; foreach (KeyValuePair property in objectInstance) { - if (schemaObject["properties"] is JsonObject explicitProperties && explicitProperties.ContainsKey(property.Key)) - { - continue; - } + bool isExplicitProperty = schemaObject["properties"] is JsonObject explicitProperties && + explicitProperties.ContainsKey(property.Key); bool matchedPattern = false; if (patternProperties is not null) @@ -1139,7 +1150,7 @@ refText is not null && } } - if (!matchedPattern && additionalProperties is not null) + if (!isExplicitProperty && !matchedPattern && additionalProperties is not null) { CollectSchemaContexts( property.Value, @@ -1172,6 +1183,24 @@ refText is not null && } } + if (instanceNode is JsonObject dependencyInstance && + schemaObject["dependentSchemas"] is JsonObject dependentSchemas) + { + foreach (KeyValuePair dependentSchema in dependentSchemas) + { + if (dependentSchema.Value is not null && dependencyInstance.ContainsKey(dependentSchema.Key)) + { + CollectSchemaContexts( + instanceNode, + dependentSchema.Value, + schemaRoot, + instancePath, + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + foreach (string compositionKeyword in new[] { "allOf", "anyOf", "oneOf" }) { if (schemaObject[compositionKeyword] is not JsonArray composition) @@ -1284,10 +1313,84 @@ private static bool TryGetSourceValue( return false; } + if (!WasOmittedBySupportedIgnoreCondition( + sourceValue, propertyInfo, serializerOptions)) + { + return false; + } + value = SerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo); return sourceValue is null || value is not null; } + private static bool WasOmittedBySupportedIgnoreCondition( + object? value, + JsonPropertyInfo? propertyInfo, + JsonSerializerOptions serializerOptions) + { + JsonIgnoreAttribute? ignoreAttribute = propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider + ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true) + .OfType() + .FirstOrDefault() + : null; + + bool omittedByMemberIgnore = ignoreAttribute?.Condition switch + { + JsonIgnoreCondition.WhenWritingNull => value is null, + JsonIgnoreCondition.WhenWritingDefault => + propertyInfo is not null && IsDefaultValue(value, propertyInfo.PropertyType), + _ => false, + }; + + if (propertyInfo?.ShouldSerialize is not null && !omittedByMemberIgnore) + { + // A caller-provided predicate may enforce redaction. Its result must not be + // overridden by recovering a value that the member ignore rule would serialize. + return false; + } + + JsonIgnoreCondition ignoreCondition = ignoreAttribute?.Condition ?? serializerOptions.DefaultIgnoreCondition; + return ignoreCondition switch + { + JsonIgnoreCondition.WhenWritingNull => value is null, + // Global default-ignore omissions are present in the full serialization with + // DefaultIgnoreCondition.Never. Only member-level ignores need source recovery. + JsonIgnoreCondition.WhenWritingDefault => + ignoreAttribute is not null && + propertyInfo is not null && + IsDefaultValue(value, propertyInfo.PropertyType), + _ => false, + }; + } + + private static bool IsDefaultValue(object? value, Type valueType) + { + if (value is null) + { + return true; + } + + Type effectiveType = Nullable.GetUnderlyingType(valueType) ?? valueType; + return effectiveType.IsValueType && value.Equals(CreateDefaultValue(effectiveType)); + } + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2067:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", + Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2072:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", + Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] + private static object CreateDefaultValue(Type valueType) + { +#if NETSTANDARD2_0 + return System.Runtime.Serialization.FormatterServices.GetUninitializedObject(valueType); +#else + return System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(valueType); +#endif + } + private static bool TryGetSourceObjectAtPath( object sourceRoot, string path, @@ -1352,9 +1455,9 @@ private static bool TryGetSourceObjectAtPath( return false; } - if (value is IDictionary dictionary && dictionary.Contains(segment)) + if (value is IDictionary dictionary && TryGetDictionaryValue(dictionary, segment, serializerOptions, out object? dictionaryValue)) { - value = dictionary[segment]; + value = dictionaryValue; continue; } @@ -1390,6 +1493,62 @@ private static bool TryGetSourceObjectAtPath( return true; } + private static bool TryGetDictionaryValue( + IDictionary dictionary, + string propertyName, + JsonSerializerOptions serializerOptions, + out object? value) + { + value = null; + JsonTypeInfo dictionaryTypeInfo; + try + { + dictionaryTypeInfo = serializerOptions.GetTypeInfo(dictionary.GetType()); + } + catch (NotSupportedException) + { + return false; + } + + if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary || + dictionaryTypeInfo.KeyType is null || + dictionaryTypeInfo.ElementType is not { } valueType || + dictionaryTypeInfo.CreateObject?.Invoke() is not IDictionary singleEntry) + { + return false; + } + + object? placeholderValue = valueType.IsValueType + ? CreateDefaultValue(valueType) + : null; + + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key is null) + { + continue; + } + + try + { + singleEntry.Clear(); + singleEntry.Add(entry.Key, placeholderValue); + JsonElement serialized = JsonSerializer.SerializeToElement(singleEntry, dictionaryTypeInfo); + if (serialized.ValueKind == JsonValueKind.Object && serialized.TryGetProperty(propertyName, out _)) + { + value = entry.Value; + return true; + } + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or InvalidOperationException) + { + return false; + } + } + + return false; + } + private static bool TryGetSourceMember( object source, string propertyName, @@ -1471,10 +1630,18 @@ private static bool TryGetSourceMember( { JsonSerializerOptions effectiveOptions = serializerOptions; JsonConverter? propertyConverter = GetPropertyConverter(propertyInfo, valueType); - if (propertyConverter is not null) + if (propertyConverter is not null || propertyInfo?.NumberHandling is not null) { effectiveOptions = new JsonSerializerOptions(serializerOptions); - effectiveOptions.Converters.Insert(0, propertyConverter); + if (propertyConverter is not null) + { + effectiveOptions.Converters.Insert(0, propertyConverter); + } + + if (propertyInfo?.NumberHandling is { } numberHandling) + { + effectiveOptions.NumberHandling = numberHandling; + } } JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index eed1447e3..ff79b7c04 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -974,6 +974,207 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_HandlesNotConditionalF AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRestoreCallerFilteredProperty() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"secret":{"type":"string"}},"required":["secret"]}""").RootElement; + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ExplicitSchemaFilteredOutput)) + { + typeInfo.Properties.Single(property => property.Name == "secret").ShouldSerialize = (_, _) => false; + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaFilteredOutput { Secret = "do-not-publish" }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("secret", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeAlreadyValidOutputAgain() + { + int getterCalls = 0; + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new GetterCountedStructuredOutput(() => getterCalls++), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("42", result.StructuredContent.Value.GetProperty("id").GetString()); + // The result is serialized once for structured content and once for text content. + Assert.Equal(2, getterCalls); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedDefaultValueInPasses() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"child":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}},"required":["child"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NestedDefaultedStructuredOutput { Child = default }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentSchema() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"dependentSchemas":{"id":{"required":["note"]}}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesMatchingPatternForExplicitProperty() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"child":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}},"patternProperties":{"^child$":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFromBroadUnion() + { + string branchSchemas = string.Join(",", Enumerable.Range(0, 10).Select(index => + $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index}}}":{"type":["string","null"]}},"required":["tag","note{{{index}}}"],"additionalProperties":false}""")); + using JsonDocument schemaDocument = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branchSchemas}}]}"""); + JsonElement outputSchema = schemaDocument.RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new TenBranchStructuredOutput { Tag = "0" }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note0").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("note1", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInventOneOfProperty() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","oneOf":[{"properties":{"note":{"type":["string","null"]}},"required":["note"]},{"properties":{"other":{"type":["string","null"]}},"required":["other"]}]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOneOfSourceOnlyOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversIntegerDictionaryKey() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"object","properties":{"42":{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}},"required":["42"]}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new IntegerKeyDictionaryOutput { Items = new() { [42] = new() { Count = 0 } } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesPropertyNumberHandlingOnRecovery() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"string"}},"required":["count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithNumberHandling { Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1677,6 +1878,97 @@ private sealed class NumericConstOutput public string? Note { get; init; } } + private sealed class ExplicitSchemaFilteredOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public required string Secret { get; init; } + } + + private sealed class GetterCountedStructuredOutput(Action onGet) + { + public string Id + { + get + { + onGet(); + return "42"; + } + } + } + + private struct NestedDefaultedStructuredChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + + private sealed class NestedDefaultedStructuredOutput + { + public NestedDefaultedStructuredChild Child { get; init; } + } + + private sealed class ExplicitSchemaPatternChildOutput + { + public required ExplicitSchemaPatternChild Child { get; init; } + } + + private sealed class ExplicitSchemaPatternChild + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class TenBranchStructuredOutput + { + public required string Tag { get; init; } + + public string? Note0 { get; init; } + + public string? Note1 { get; init; } + + public string? Note2 { get; init; } + + public string? Note3 { get; init; } + + public string? Note4 { get; init; } + + public string? Note5 { get; init; } + + public string? Note6 { get; init; } + + public string? Note7 { get; init; } + + public string? Note8 { get; init; } + + public string? Note9 { get; init; } + } + + private sealed class ExplicitSchemaOneOfSourceOnlyOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class IntegerKeyDictionaryOutput + { + public required Dictionary Items { get; init; } + } + + private sealed class IntegerKeyDictionaryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + private sealed class ExplicitSchemaOutputWithNumberHandling + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public int Count { get; init; } + } + private sealed class RecursiveStructuredOutput { public required string Id { get; init; } From 0170ae672019cd5ea2c27b423765620e57f4fcd6 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 23 Oct 2026 10:26:03 +0800 Subject: [PATCH 06/30] Add regression for structured output source lookups > [!NOTE] > AI-generated by GitHub Copilot. --- .../Server/McpServerToolTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index ff79b7c04..acb7505c9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1022,6 +1022,33 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeAlread Assert.Equal(2, getterCalls); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotLookUpValuesPresentInCompleteSnapshot() + { + const int itemCount = 100; + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id","note"]}}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + GetterCountedCollectionStructuredOutput output = new( + Enumerable.Range(0, itemCount).Select(index => new ExplicitSchemaCollectionItem { Id = index.ToString() }).ToList()); + McpServerTool tool = McpServerTool.Create( + () => output, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(itemCount, result.StructuredContent.Value.GetProperty("items").GetArrayLength()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + // Structured output, text output, and the complete-value snapshot each access the collection once. + Assert.Equal(3, output.ItemsGetterCalls); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedDefaultValueInPasses() { @@ -1896,6 +1923,27 @@ public string Id } } + private sealed class GetterCountedCollectionStructuredOutput(List items) + { + public int ItemsGetterCalls { get; private set; } + + public List Items + { + get + { + ItemsGetterCalls++; + return items; + } + } + } + + private sealed class ExplicitSchemaCollectionItem + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + private struct NestedDefaultedStructuredChild { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] From dfed8d35951e9d8dc56d8144bbdbfccf5874d34e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 09:23:10 +0800 Subject: [PATCH 07/30] Fix structured content schema recovery --- .../Server/AIFunctionMcpServerTool.cs | 335 ++++++++++-------- .../Server/McpServerToolTests.cs | 139 ++++++++ 2 files changed, 323 insertions(+), 151 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 050dc3318..078223c9f 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -755,10 +755,19 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) if (originalResult is not null and not JsonElement and not JsonNode && AIFunction.JsonSerializerOptions.DefaultIgnoreCondition is not JsonIgnoreCondition.Never) { - JsonElement completeResult = JsonSerializer.SerializeToElement( - originalResult, _serializerTypeInfoIncludingDefaults.Value); - resultNodeIncludingDefaults = JsonSerializer.SerializeToNode( - completeResult, McpJsonUtilities.JsonContext.Default.JsonElement); + try + { + JsonElement completeResult = JsonSerializer.SerializeToElement( + originalResult, _serializerTypeInfoIncludingDefaults.Value); + resultNodeIncludingDefaults = JsonSerializer.SerializeToNode( + completeResult, McpJsonUtilities.JsonContext.Default.JsonElement); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) + { + // A value omitted from the actual result may fail when the ignore policy is + // relaxed. Required members can still be recovered individually from metadata. + resultNodeIncludingDefaults = null; + } } JsonNode? repairedResult = RepairStructuredResult( @@ -795,39 +804,145 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return null; } - JsonNode workingResult = resultNode.DeepClone(); - for (int pass = 0; pass < 32; pass++) + return TryRepairStructuredResult( + resultNode, + resultNodeIncludingDefaults, + schemaNode, + schema, + originalResult, + serializerOptions, + depth: 0, + new RepairSearchState()); + } + + private static JsonNode? TryRepairStructuredResult( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + JsonNode schemaNode, + JsonSchema schema, + object? originalResult, + JsonSerializerOptions serializerOptions, + int depth, + RepairSearchState searchState) + { + if (IsSchemaValid(schema, resultNode)) + { + return resultNode.DeepClone(); + } + + const int maxRepairDepth = 32; + const int maxCandidateSetEvaluations = 256; + if (depth >= maxRepairDepth || searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations || + !searchState.VisitedStates.Add(resultNode.ToJsonString())) { - Dictionary contexts = new(StringComparer.Ordinal); - CollectSchemaContexts( - workingResult, - schemaNode, - schemaNode, - string.Empty, - contexts, - new HashSet(StringComparer.Ordinal)); + return null; + } + + Dictionary contexts = new(StringComparer.Ordinal); + CollectSchemaContexts( + resultNode, + schemaNode, + schemaNode, + string.Empty, + contexts, + new HashSet(StringComparer.Ordinal)); + + List candidates = CreateRepairCandidates( + resultNode, + resultNodeIncludingDefaults, + originalResult, + serializerOptions, + contexts); + if (candidates.Count == 0) + { + return null; + } + + bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); + string originalJson = resultNode.ToJsonString(); + JsonNode? repairedResult = null; + + bool TryCurrentSet() + { + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + { + return false; + } + + searchState.CandidateSetEvaluations++; + JsonNode candidateResult = resultNode.DeepClone(); + bool hasIncludedCandidate = false; + for (int i = 0; i < candidates.Count; i++) + { + if (included[i]) + { + ApplyRepairCandidate(candidateResult, candidates[i]); + hasIncludedCandidate = true; + } + } - List candidates = CreateRepairCandidates( - workingResult, + if (!hasIncludedCandidate || string.Equals(candidateResult.ToJsonString(), originalJson, StringComparison.Ordinal)) + { + return false; + } + + repairedResult = TryRepairStructuredResult( + candidateResult, resultNodeIncludingDefaults, + schemaNode, + schema, originalResult, serializerOptions, - contexts); + depth + 1, + searchState); + return repairedResult is not null; + } + + bool SearchInclusions(int nextIndex, int remaining) + { + if (remaining == 0) + { + return TryCurrentSet(); + } - if (candidates.Count == 0 || !TryFindCandidateSet(schema, workingResult, candidates, out JsonNode? repaired)) + for (int i = nextIndex; i <= candidates.Count - remaining; i++) { - return null; + included[i] = true; + if (SearchInclusions(i + 1, remaining - 1)) + { + return true; + } + + included[i] = false; + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + { + return false; + } } - if (repaired is null) + return false; + } + + if (TryCurrentSet()) + { + return repairedResult; + } + + for (int i = 0; i < included.Length; i++) + { + included[i] = false; + } + + for (int includedCount = 1; includedCount < candidates.Count; includedCount++) + { + if (SearchInclusions(0, includedCount)) { - return null; + return repairedResult; } - workingResult = repaired; - if (IsSchemaValid(schema, workingResult)) + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) { - return workingResult; + break; } } @@ -929,115 +1044,6 @@ private static List CreateRepairCandidates( return candidates; } - private static bool TryFindCandidateSet( - JsonSchema schema, - JsonNode originalResult, - IReadOnlyList candidates, - out JsonNode? repairedResult) - { - repairedResult = null; - if (candidates.Count == 0) - { - return false; - } - - // Prefer the full repair first, then small subsets so discriminated unions do not - // spend the evaluation budget excluding unrelated branch properties. - const int maxCandidateSetEvaluations = 256; - bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); - int evaluations = 0; - JsonNode? matchingResult = null; - - bool TryCurrentSet() - { - if (evaluations >= maxCandidateSetEvaluations) - { - return false; - } - - evaluations++; - JsonNode candidateResult = originalResult.DeepClone(); - bool hasIncludedCandidate = false; - for (int i = 0; i < candidates.Count; i++) - { - if (included[i]) - { - ApplyRepairCandidate(candidateResult, candidates[i]); - hasIncludedCandidate = true; - } - } - - if (!hasIncludedCandidate || !IsSchemaValid(schema, candidateResult)) - { - return false; - } - - matchingResult = candidateResult; - return true; - } - - bool SearchInclusions(int nextIndex, int remaining) - { - if (remaining == 0) - { - return TryCurrentSet(); - } - - for (int i = nextIndex; i <= candidates.Count - remaining; i++) - { - included[i] = true; - if (SearchInclusions(i + 1, remaining - 1)) - { - return true; - } - - included[i] = false; - if (evaluations >= maxCandidateSetEvaluations) - { - return false; - } - } - - return false; - } - - if (TryCurrentSet()) - { - repairedResult = matchingResult; - return true; - } - - for (int i = 0; i < included.Length; i++) - { - included[i] = false; - } - - for (int includedCount = 1; includedCount < candidates.Count; includedCount++) - { - if (SearchInclusions(0, includedCount)) - { - repairedResult = matchingResult; - return true; - } - - if (evaluations >= maxCandidateSetEvaluations) - { - break; - } - } - - // A restored parent may expose required descendants on the next pass. Only - // source-backed candidates are created, and the caller publishes a result only - // after the complete schema validates. - repairedResult = originalResult.DeepClone(); - foreach (RepairCandidate candidate in candidates) - { - ApplyRepairCandidate(repairedResult, candidate); - } - - return true; - } - private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate) { if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject) @@ -1053,6 +1059,13 @@ private sealed class SchemaObjectContext private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value); + private sealed class RepairSearchState + { + internal int CandidateSetEvaluations { get; set; } + + internal HashSet VisitedStates { get; } = new(StringComparer.Ordinal); + } + private static void CollectSchemaContexts( JsonNode? instanceNode, JsonNode? schemaNode, @@ -1184,11 +1197,29 @@ refText is not null && } if (instanceNode is JsonObject dependencyInstance && + schemaObject["dependentRequired"] is JsonObject dependentRequired) + { + foreach (KeyValuePair dependency in dependentRequired) + { + if (dependencyInstance.ContainsKey(dependency.Key) && dependency.Value is JsonArray requiredProperties) + { + foreach (JsonNode? requiredProperty in requiredProperties) + { + if (requiredProperty?.GetValue() is string propertyName) + { + context.Required.Add(propertyName); + } + } + } + } + } + + if (instanceNode is JsonObject dependencyInstanceForSchemas && schemaObject["dependentSchemas"] is JsonObject dependentSchemas) { foreach (KeyValuePair dependentSchema in dependentSchemas) { - if (dependentSchema.Value is not null && dependencyInstance.ContainsKey(dependentSchema.Key)) + if (dependentSchema.Value is not null && dependencyInstanceForSchemas.ContainsKey(dependentSchema.Key)) { CollectSchemaContexts( instanceNode, @@ -1314,7 +1345,7 @@ private static bool TryGetSourceValue( } if (!WasOmittedBySupportedIgnoreCondition( - sourceValue, propertyInfo, serializerOptions)) + sourceParent, sourceValue, propertyInfo, serializerOptions)) { return false; } @@ -1324,6 +1355,7 @@ private static bool TryGetSourceValue( } private static bool WasOmittedBySupportedIgnoreCondition( + object sourceParent, object? value, JsonPropertyInfo? propertyInfo, JsonSerializerOptions serializerOptions) @@ -1334,18 +1366,10 @@ private static bool WasOmittedBySupportedIgnoreCondition( .FirstOrDefault() : null; - bool omittedByMemberIgnore = ignoreAttribute?.Condition switch + if (propertyInfo?.ShouldSerialize is { } shouldSerialize && + !IsJsonIgnoreConditionPredicate(shouldSerialize) && + !shouldSerialize(sourceParent, value)) { - JsonIgnoreCondition.WhenWritingNull => value is null, - JsonIgnoreCondition.WhenWritingDefault => - propertyInfo is not null && IsDefaultValue(value, propertyInfo.PropertyType), - _ => false, - }; - - if (propertyInfo?.ShouldSerialize is not null && !omittedByMemberIgnore) - { - // A caller-provided predicate may enforce redaction. Its result must not be - // overridden by recovering a value that the member ignore rule would serialize. return false; } @@ -1353,16 +1377,24 @@ private static bool WasOmittedBySupportedIgnoreCondition( return ignoreCondition switch { JsonIgnoreCondition.WhenWritingNull => value is null, - // Global default-ignore omissions are present in the full serialization with - // DefaultIgnoreCondition.Never. Only member-level ignores need source recovery. JsonIgnoreCondition.WhenWritingDefault => - ignoreAttribute is not null && propertyInfo is not null && IsDefaultValue(value, propertyInfo.PropertyType), _ => false, }; } + // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. + private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) + { + Type? generatedClosureType = predicate.Method.DeclaringType; + Type? propertyInfoType = generatedClosureType?.DeclaringType; + + return predicate.Method.Name.StartsWith("b__", StringComparison.Ordinal) && + propertyInfoType?.Assembly == typeof(JsonPropertyInfo).Assembly && + string.Equals(propertyInfoType.Name, "JsonPropertyInfo`1", StringComparison.Ordinal); + } + private static bool IsDefaultValue(object? value, Type valueType) { if (value is null) @@ -1370,8 +1402,9 @@ private static bool IsDefaultValue(object? value, Type valueType) return true; } - Type effectiveType = Nullable.GetUnderlyingType(valueType) ?? valueType; - return effectiveType.IsValueType && value.Equals(CreateDefaultValue(effectiveType)); + return Nullable.GetUnderlyingType(valueType) is null && + valueType.IsValueType && + value.Equals(CreateDefaultValue(valueType)); } [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index acb7505c9..15008f654 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -620,6 +620,30 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesMemberConvert AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversRequiredNullWhenCompleteSnapshotFails() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"required":["note"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithUndefinedEnumDefault { Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("status", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences() { @@ -1000,6 +1024,37 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRestoreCallerFi Assert.False(result.StructuredContent.Value.TryGetProperty("secret", out _)); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsCallerFilterForMemberDefault() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"integer"},"nullableCount":{"type":"integer"}},"required":["count","nullableCount"]}""").RootElement; + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ExplicitSchemaFilteredDefaultOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.ShouldSerialize = (_, _) => false; + } + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaFilteredDefaultOutput(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("count", out _)); + Assert.False(result.StructuredContent.Value.TryGetProperty("nullableCount", out _)); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeAlreadyValidOutputAgain() { @@ -1093,6 +1148,28 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropert AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentRequired() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id"],"dependentRequired":{"id":["note"]}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesMatchingPatternForExplicitProperty() { @@ -1162,6 +1239,34 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInventOneOfProp AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedOneOfWithoutMixingBranches() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "oneOf": [ + {"type":"object","properties":{"first":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["first"],"additionalProperties":false}, + {"type":"object","properties":{"second":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["second"],"additionalProperties":false} + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NestedOneOfRecoveryOutput(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("first").GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("second", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversIntegerDictionaryKey() { @@ -1862,6 +1967,18 @@ private sealed class ExplicitSchemaOutputWithMemberConverter public int Count { get; init; } } + private sealed class ExplicitSchemaOutputWithUndefinedEnumDefault + { + public string? Note { get; init; } + + public ExplicitSchemaStatus Status { get; init; } + } + + private enum ExplicitSchemaStatus + { + Ready = 1, + } + private sealed class IntAsStringJsonConverter : JsonConverter { public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32(); @@ -1911,6 +2028,15 @@ private sealed class ExplicitSchemaFilteredOutput public required string Secret { get; init; } } + private sealed class ExplicitSchemaFilteredDefaultOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int? NullableCount { get; init; } = 0; + } + private sealed class GetterCountedStructuredOutput(Action onGet) { public string Id @@ -1950,6 +2076,19 @@ private struct NestedDefaultedStructuredChild public string? Note { get; init; } } + private struct NestedOneOfRecoveryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + + private sealed class NestedOneOfRecoveryOutput + { + public NestedOneOfRecoveryChild First { get; init; } + + public NestedOneOfRecoveryChild Second { get; init; } + } + private sealed class NestedDefaultedStructuredOutput { public NestedDefaultedStructuredChild Child { get; init; } From f75698860951cad8fb4f947620b65c1870c10fbf Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 11:25:35 +0800 Subject: [PATCH 08/30] Test structured output schema edge cases --- .../Server/McpServerToolTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 15008f654..0bc0e3955 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -831,6 +831,33 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesSplitRequiredA AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesReferenceRequirementsWithSiblingProperties() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { "requiresNote": { "type": "object", "required": ["note"] } }, + "$ref": "#/$defs/requiresNote", + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMemberLevelDefaultIgnore() { @@ -917,6 +944,36 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsLargeIntegerBra AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMathematicallyIntegralDecimal() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "anyOf": [ + { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] }, + { "type": "null" } + ] + } + """).RootElement; + using JsonDocument integerDocument = JsonDocument.Parse("1.0"); + JsonElement integerValue = integerDocument.RootElement.Clone(); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new MathematicalIntegerStructuredOutput { Value = integerValue, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(1, result.StructuredContent.Value.GetProperty("value").GetDouble()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralConstEquality() { @@ -945,6 +1002,34 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralConstEqu AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralEnumEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "tag": { "enum": [{ "b": 2, "a": 1 }] }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] }, + { "properties": { "tag": { "enum": [{ "different": true }] } }, "required": ["tag"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_UsesJsonNumericEquality() { @@ -2008,6 +2093,13 @@ private sealed class LargeIntegerStructuredOutput public string? Note { get; init; } } + private sealed class MathematicalIntegerStructuredOutput + { + public JsonElement Value { get; init; } + + public string? Note { get; init; } + } + private sealed class StructuralConstOutput { public required JsonObject Tag { get; init; } From e294aeb6ac692d4efabb5340047d417df097aa41 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 26 Oct 2026 14:16:24 +0800 Subject: [PATCH 09/30] Cover branch-isolated structured output recovery --- .../Server/McpServerToolTests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 0bc0e3955..25afa3536 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1352,6 +1352,54 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedOneOfWith AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMatchingOneOfBranchWithoutOtherCandidates() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "oneOf": [ + { + "properties": { + "tag": { "const": "0" }, + "child": { + "type": "object", + "properties": { "note": { "type": ["string", "null"] } }, + "required": ["note"] + } + }, + "required": ["tag", "child"], + "additionalProperties": false + }, + { + "properties": { + "tag": { "const": "1" }, + "other": { "type": ["string", "null"] } + }, + "required": ["tag", "other"], + "additionalProperties": false + } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new BranchSpecificOneOfRecoveryOutput { Tag = "0", Child = default, Other = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("tag").GetString()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversIntegerDictionaryKey() { @@ -2181,6 +2229,21 @@ private sealed class NestedOneOfRecoveryOutput public NestedOneOfRecoveryChild Second { get; init; } } + private sealed class BranchSpecificOneOfRecoveryOutput + { + public required string Tag { get; init; } + + public BranchSpecificOneOfRecoveryChild Child { get; init; } + + public string? Other { get; init; } + } + + private struct BranchSpecificOneOfRecoveryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + private sealed class NestedDefaultedStructuredOutput { public NestedDefaultedStructuredChild Child { get; init; } From 5dd99de8a91a9eb035a8f20989566cf85ccd1a51 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 27 Oct 2026 20:40:11 +0800 Subject: [PATCH 10/30] Fix structured output repair edge cases --- .../Server/AIFunctionMcpServerTool.cs | 60 +++++++--- .../Server/McpServerToolTests.cs | 104 +++++++++++++++++- 2 files changed, 141 insertions(+), 23 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 078223c9f..1a8933a92 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -861,6 +861,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); string originalJson = resultNode.ToJsonString(); JsonNode? repairedResult = null; + List pendingResults = []; bool TryCurrentSet() { @@ -886,16 +887,14 @@ bool TryCurrentSet() return false; } - repairedResult = TryRepairStructuredResult( - candidateResult, - resultNodeIncludingDefaults, - schemaNode, - schema, - originalResult, - serializerOptions, - depth + 1, - searchState); - return repairedResult is not null; + if (IsSchemaValid(schema, candidateResult)) + { + repairedResult = candidateResult; + return true; + } + + pendingResults.Add(candidateResult); + return false; } bool SearchInclusions(int nextIndex, int remaining) @@ -927,13 +926,15 @@ bool SearchInclusions(int nextIndex, int remaining) { return repairedResult; } + JsonNode? allCandidatesResult = pendingResults.Count == 0 ? null : pendingResults[0]; + pendingResults.Clear(); for (int i = 0; i < included.Length; i++) { included[i] = false; } - for (int includedCount = 1; includedCount < candidates.Count; includedCount++) + for (int includedCount = 1; includedCount <= Math.Min(2, candidates.Count - 1); includedCount++) { if (SearchInclusions(0, includedCount)) { @@ -946,6 +947,31 @@ bool SearchInclusions(int nextIndex, int remaining) } } + if (allCandidatesResult is not null) + { + pendingResults.Add(allCandidatesResult); + } + + foreach (JsonNode pendingResult in pendingResults) + { + searchState.Pending.Enqueue((pendingResult, depth + 1)); + } + + if (depth == 0) + { + while (searchState.Pending.Count > 0 && searchState.CandidateSetEvaluations < maxCandidateSetEvaluations) + { + (JsonNode nextResult, int nextDepth) = searchState.Pending.Dequeue(); + repairedResult = TryRepairStructuredResult( + nextResult, resultNodeIncludingDefaults, schemaNode, schema, + originalResult, serializerOptions, nextDepth, searchState); + if (repairedResult is not null) + { + return repairedResult; + } + } + } + return null; } @@ -1064,6 +1090,8 @@ private sealed class RepairSearchState internal int CandidateSetEvaluations { get; set; } internal HashSet VisitedStates { get; } = new(StringComparer.Ordinal); + + internal Queue<(JsonNode Result, int Depth)> Pending { get; } = new(); } private static void CollectSchemaContexts( @@ -1551,10 +1579,6 @@ dictionaryTypeInfo.ElementType is not { } valueType || return false; } - object? placeholderValue = valueType.IsValueType - ? CreateDefaultValue(valueType) - : null; - foreach (DictionaryEntry entry in dictionary) { if (entry.Key is null) @@ -1565,7 +1589,7 @@ dictionaryTypeInfo.ElementType is not { } valueType || try { singleEntry.Clear(); - singleEntry.Add(entry.Key, placeholderValue); + singleEntry.Add(entry.Key, entry.Value); JsonElement serialized = JsonSerializer.SerializeToElement(singleEntry, dictionaryTypeInfo); if (serialized.ValueKind == JsonValueKind.Object && serialized.TryGetProperty(propertyName, out _)) { @@ -1573,7 +1597,7 @@ dictionaryTypeInfo.ElementType is not { } valueType || return true; } } - catch (Exception exception) when (exception is ArgumentException or NotSupportedException or InvalidOperationException) + catch (Exception exception) when (exception is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) { return false; } @@ -1681,7 +1705,7 @@ private static bool TryGetSourceMember( JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); return JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); } - catch (NotSupportedException) + catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) { return null; } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index f25fcf449..2c3210d56 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -679,6 +679,51 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversRequiredNullWh AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RejectingEnumDoesNotPreventUnionRepair() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"anyOf":[{"required":["status"]},{"required":["note"]}]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithUndefinedEnumDefault(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversStructDictionaryValueWithEnumConverter() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"object","additionalProperties":{"type":"object","properties":{"count":{"type":"integer"},"status":{"type":"string"}},"required":["count","status"]}}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new StructDictionaryOutput { Items = new() { [42] = new() { Status = ExplicitSchemaStatus.Ready } } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences() { @@ -1312,8 +1357,18 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesMatchingPatte AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } - [Fact] - public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFromBroadUnion() + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFromBroadUnion(int tag) { string branchSchemas = string.Join(",", Enumerable.Range(0, 10).Select(index => $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index}}}":{"type":["string","null"]}},"required":["tag","note{{{index}}}"],"additionalProperties":false}""")); @@ -1325,15 +1380,41 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFro TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), }; McpServerTool tool = McpServerTool.Create( - () => new TenBranchStructuredOutput { Tag = "0" }, + () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag}").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsBranchWithTwoRequiredMembers(int tag) + { + string branches = string.Join(",", Enumerable.Range(0, 3).Select(index => + $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 2}}}":{"type":["string","null"]},"note{{{index * 2 + 1}}}":{"type":["string","null"]}},"required":["tag","note{{{index * 2}}}","note{{{index * 2 + 1}}}"],"additionalProperties":false}""")); + using JsonDocument document = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branches}}]}"""); + JsonElement outputSchema = document.RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); Assert.NotNull(result.StructuredContent); - Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note0").ValueKind); - Assert.False(result.StructuredContent.Value.TryGetProperty("note1", out _)); AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2}").ValueKind); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2 + 1}").ValueKind); } [Fact] @@ -2142,6 +2223,19 @@ private sealed class ExplicitSchemaOutputWithUndefinedEnumDefault public ExplicitSchemaStatus Status { get; init; } } + private sealed class StructDictionaryOutput + { + public required Dictionary Items { get; init; } + } + + private struct StructDictionaryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; set; } + + public ExplicitSchemaStatus Status { get; set; } + } + private enum ExplicitSchemaStatus { Ready = 1, From 4dfae0e5e57f4ee28350361cf3403b454e483e82 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Tue, 27 Oct 2026 14:53:49 -0700 Subject: [PATCH 11/30] Fix structured output recovery edge cases --- .../Server/AIFunctionMcpServerTool.cs | 476 ++++++++++-------- .../Program.cs | 65 +++ .../Server/McpServerToolTests.cs | 391 +++++++++++++- 3 files changed, 716 insertions(+), 216 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 1a8933a92..a1b2fddb2 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -19,7 +19,7 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; - private readonly Lazy _outputSchema; + private OutputSchemaCache? _outputSchema; private readonly Lazy _serializerTypeInfoIncludingDefaults; /// @@ -230,7 +230,6 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider AIFunction = function; ProtocolTool = tool; - _outputSchema = new Lazy(BuildOutputSchema, LazyThreadSafetyMode.ExecutionAndPublication); _serializerTypeInfoIncludingDefaults = new Lazy(() => { JsonSerializerOptions options = new(AIFunction.JsonSerializerOptions) @@ -739,8 +738,9 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); - JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - JsonSchema? schema = _outputSchema.Value; + OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); + JsonNode? schemaNode = cachedSchema.Node; + JsonSchema? schema = cachedSchema.Schema; if (resultNode is null || schemaNode is null || schema is null) { return elementResult; @@ -804,172 +804,80 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return null; } - return TryRepairStructuredResult( - resultNode, - resultNodeIncludingDefaults, - schemaNode, - schema, - originalResult, - serializerOptions, - depth: 0, - new RepairSearchState()); - } - - private static JsonNode? TryRepairStructuredResult( - JsonNode resultNode, - JsonNode? resultNodeIncludingDefaults, - JsonNode schemaNode, - JsonSchema schema, - object? originalResult, - JsonSerializerOptions serializerOptions, - int depth, - RepairSearchState searchState) - { - if (IsSchemaValid(schema, resultNode)) - { - return resultNode.DeepClone(); - } - const int maxRepairDepth = 32; const int maxCandidateSetEvaluations = 256; - if (depth >= maxRepairDepth || searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations || - !searchState.VisitedStates.Add(resultNode.ToJsonString())) - { - return null; - } - - Dictionary contexts = new(StringComparer.Ordinal); - CollectSchemaContexts( - resultNode, - schemaNode, - schemaNode, - string.Empty, - contexts, - new HashSet(StringComparer.Ordinal)); - - List candidates = CreateRepairCandidates( - resultNode, - resultNodeIncludingDefaults, - originalResult, - serializerOptions, - contexts); - if (candidates.Count == 0) - { - return null; - } - - bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); - string originalJson = resultNode.ToJsonString(); - JsonNode? repairedResult = null; - List pendingResults = []; + Queue pending = new(); + Queue fallback = new(); + HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; - bool TryCurrentSet() + void EnqueueRepairs(JsonNode instance, int depth) { - if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) - { - return false; - } - - searchState.CandidateSetEvaluations++; - JsonNode candidateResult = resultNode.DeepClone(); - bool hasIncludedCandidate = false; - for (int i = 0; i < candidates.Count; i++) + if (depth >= maxRepairDepth) { - if (included[i]) - { - ApplyRepairCandidate(candidateResult, candidates[i]); - hasIncludedCandidate = true; - } + return; } - if (!hasIncludedCandidate || string.Equals(candidateResult.ToJsonString(), originalJson, StringComparison.Ordinal)) + Dictionary contexts = new(StringComparer.Ordinal); + List> groups = []; + CollectSchemaContexts( + instance, schemaNode, schemaNode, string.Empty, contexts, + new HashSet(StringComparer.Ordinal), groups); + List candidates = CreateRepairCandidates( + instance, resultNodeIncludingDefaults, originalResult, serializerOptions, contexts); + if (candidates.Count == 0) { - return false; + return; } - if (IsSchemaValid(schema, candidateResult)) + pending.Enqueue(new(instance, candidates, depth + 1)); + HashSet grouped = new(groups.SelectMany(static group => group), StringComparer.Ordinal); + foreach (HashSet group in groups) { - repairedResult = candidateResult; - return true; + List branchCandidates = candidates.Where(candidate => + group.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList(); + if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count && + pending.Count < maxCandidateSetEvaluations) + { + pending.Enqueue(new(instance, branchCandidates, depth + 1)); + } } - pendingResults.Add(candidateResult); - return false; - } - - bool SearchInclusions(int nextIndex, int remaining) - { - if (remaining == 0) + for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++) { - return TryCurrentSet(); + fallback.Enqueue(new(instance, [candidates[i]], depth + 1)); } - for (int i = nextIndex; i <= candidates.Count - remaining; i++) + for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++) { - included[i] = true; - if (SearchInclusions(i + 1, remaining - 1)) - { - return true; - } - - included[i] = false; - if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + for (int j = i + 1; j < candidates.Count && fallback.Count < maxCandidateSetEvaluations; j++) { - return false; + fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1)); } } - - return false; - } - - if (TryCurrentSet()) - { - return repairedResult; - } - JsonNode? allCandidatesResult = pendingResults.Count == 0 ? null : pendingResults[0]; - pendingResults.Clear(); - - for (int i = 0; i < included.Length; i++) - { - included[i] = false; } - for (int includedCount = 1; includedCount <= Math.Min(2, candidates.Count - 1); includedCount++) + EnqueueRepairs(resultNode, 0); + for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) { - if (SearchInclusions(0, includedCount)) + RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue(); + JsonNode candidateResult = attempt.Result.DeepClone(); + foreach (RepairCandidate candidate in attempt.Candidates) { - return repairedResult; + ApplyRepairCandidate(candidateResult, candidate); } - if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + if (!visited.Add(candidateResult.ToJsonString())) { - break; + continue; } - } - - if (allCandidatesResult is not null) - { - pendingResults.Add(allCandidatesResult); - } - - foreach (JsonNode pendingResult in pendingResults) - { - searchState.Pending.Enqueue((pendingResult, depth + 1)); - } - if (depth == 0) - { - while (searchState.Pending.Count > 0 && searchState.CandidateSetEvaluations < maxCandidateSetEvaluations) + evaluations++; + if (IsSchemaValid(schema, candidateResult)) { - (JsonNode nextResult, int nextDepth) = searchState.Pending.Dequeue(); - repairedResult = TryRepairStructuredResult( - nextResult, resultNodeIncludingDefaults, schemaNode, schema, - originalResult, serializerOptions, nextDepth, searchState); - if (repairedResult is not null) - { - return repairedResult; - } + return candidateResult; } + + EnqueueRepairs(candidateResult, attempt.Depth); } return null; @@ -980,7 +888,7 @@ private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? sche try { JsonElement schemaElement = JsonSerializer.SerializeToElement(schemaNode, McpJsonUtilities.JsonContext.Default.JsonNode); - schema = JsonSchema.Build(schemaElement); + schema = JsonSchema.Build(schemaElement, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); return true; } catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) @@ -990,19 +898,28 @@ private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? sche } } - private JsonSchema? BuildOutputSchema() + private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) { - if (ProtocolTool.OutputSchema is not { } outputSchema) + OutputSchemaCache? cachedSchema = Volatile.Read(ref _outputSchema); + if (cachedSchema is not null && cachedSchema.Source.Equals(outputSchema)) { - return null; + return cachedSchema; } JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - return schemaNode is not null && TryBuildJsonSchema(schemaNode, out JsonSchema? schema) - ? schema - : null; + JsonSchema? schema = null; + if (schemaNode is not null) + { + TryBuildJsonSchema(schemaNode, out schema); + } + + cachedSchema = new(outputSchema, schemaNode, schema); + Volatile.Write(ref _outputSchema, cachedSchema); + return cachedSchema; } + private sealed record OutputSchemaCache(JsonElement Source, JsonNode? Node, JsonSchema? Schema); + private static bool IsSchemaValid(JsonSchema schema, JsonNode instance) { try @@ -1057,6 +974,7 @@ private static List CreateRepairCandidates( } else if (TryGetSourceValue( originalResult, + resultNode, contextEntry.Key, propertyName, serializerOptions, @@ -1083,24 +1001,22 @@ private sealed class SchemaObjectContext internal HashSet Required { get; } = new(StringComparer.Ordinal); } - private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value); - - private sealed class RepairSearchState + private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value) { - internal int CandidateSetEvaluations { get; set; } - - internal HashSet VisitedStates { get; } = new(StringComparer.Ordinal); - - internal Queue<(JsonNode Result, int Depth)> Pending { get; } = new(); + internal string Key => ParentPath + "\u0000" + PropertyName; } + private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); + private static void CollectSchemaContexts( JsonNode? instanceNode, JsonNode? schemaNode, JsonNode schemaRoot, string instancePath, Dictionary contexts, - HashSet activeReferences) + HashSet activeReferences, + List> groups, + List? dynamicScope = null) { if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) { @@ -1112,17 +1028,41 @@ private static void CollectSchemaContexts( return; } - if (schemaObject["$ref"] is JsonValue refValue && - refValue.TryGetValue(out string? refText) && - refText is not null && - activeReferences.Add(refText + "|" + instancePath)) + dynamicScope ??= [schemaRoot]; + if (!ReferenceEquals(schemaRoot, schemaObject) && schemaObject.ContainsKey("$id")) + { + schemaRoot = schemaObject; + dynamicScope = [.. dynamicScope, schemaRoot]; + } + + foreach (string referenceKeyword in new[] { "$ref", "$dynamicRef" }) { - if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + if (schemaObject[referenceKeyword] is JsonValue refValue && + refValue.TryGetValue(out string? refText) && + refText is not null && + activeReferences.Add(schemaRoot.GetPath() + "|" + refText + "|" + instancePath)) { - CollectSchemaContexts(instanceNode, referencedSchema, schemaRoot, instancePath, contexts, activeReferences); - } + if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + { + if (referenceKeyword == "$dynamicRef" && referencedSchema is JsonObject referencedObject && + referencedObject["$dynamicAnchor"]?.GetValue() is string anchor && + string.Equals(refText, "#" + anchor, StringComparison.Ordinal)) + { + foreach (JsonNode resource in dynamicScope) + { + if (TryFindSchemaAnchor(resource, anchor, out JsonNode? dynamicSchema, dynamicOnly: true)) + { + referencedSchema = dynamicSchema; + break; + } + } + } + + CollectSchemaContexts(instanceNode, referencedSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + } - activeReferences.Remove(refText + "|" + instancePath); + activeReferences.Remove(schemaRoot.GetPath() + "|" + refText + "|" + instancePath); + } } SchemaObjectContext context = GetOrCreateSchemaContext(contexts, instancePath); @@ -1159,7 +1099,8 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + new HashSet(activeReferences, StringComparer.Ordinal), + groups, dynamicScope); } } @@ -1186,7 +1127,8 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + new HashSet(activeReferences, StringComparer.Ordinal), + groups, dynamicScope); } } } @@ -1199,7 +1141,15 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + new HashSet(activeReferences, StringComparer.Ordinal), + groups, dynamicScope); + } + + if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema) + { + CollectSchemaBranch( + property.Value, unevaluatedPropertySchema, schemaRoot, + AppendJsonPointer(instancePath, property.Key), contexts, activeReferences, groups, dynamicScope); } } } @@ -1219,7 +1169,19 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + new HashSet(activeReferences, StringComparer.Ordinal), + groups, dynamicScope); + } + + foreach (string keyword in new[] { "contains", "unevaluatedItems" }) + { + if (schemaObject[keyword] is JsonNode subschema) + { + CollectSchemaBranch( + arrayInstance[i], subschema, schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, activeReferences, groups, dynamicScope); + } } } } @@ -1255,7 +1217,8 @@ refText is not null && schemaRoot, instancePath, contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + new HashSet(activeReferences, StringComparer.Ordinal), + groups, dynamicScope); } } } @@ -1271,13 +1234,16 @@ refText is not null && { if (branch is not null) { - CollectSchemaContexts( - instanceNode, - branch, - schemaRoot, - instancePath, - contexts, - new HashSet(activeReferences, StringComparer.Ordinal)); + if (compositionKeyword == "allOf") + { + CollectSchemaContexts( + instanceNode, branch, schemaRoot, instancePath, contexts, + new HashSet(activeReferences, StringComparer.Ordinal), groups, dynamicScope); + } + else + { + CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + } } } } @@ -1286,14 +1252,44 @@ refText is not null && { if (schemaObject["then"] is JsonNode thenSchema) { - CollectSchemaContexts(instanceNode, thenSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); } if (schemaObject["else"] is JsonNode elseSchema) { - CollectSchemaContexts(instanceNode, elseSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + } + } + } + + private static void CollectSchemaBranch( + JsonNode? instanceNode, + JsonNode branch, + JsonNode schemaRoot, + string instancePath, + Dictionary contexts, + HashSet activeReferences, + List> groups, + List dynamicScope) + { + Dictionary branchContexts = new(StringComparer.Ordinal); + CollectSchemaContexts( + instanceNode, branch, schemaRoot, instancePath, branchContexts, + new HashSet(activeReferences, StringComparer.Ordinal), groups, dynamicScope); + HashSet group = new(StringComparer.Ordinal); + foreach (KeyValuePair entry in branchContexts) + { + GetOrCreateSchemaContext(contexts, entry.Key).Required.UnionWith(entry.Value.Required); + foreach (string property in entry.Value.Required) + { + group.Add(entry.Key + "\u0000" + property); } } + + if (group.Count > 0) + { + groups.Add(group); + } } private static SchemaObjectContext GetOrCreateSchemaContext(Dictionary contexts, string instancePath) @@ -1350,13 +1346,14 @@ private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? private static bool TryGetSourceValue( object? sourceRoot, + JsonNode serializedRoot, string parentPath, string propertyName, JsonSerializerOptions serializerOptions, out JsonNode? value) { value = null; - if (sourceRoot is null || !TryGetSourceObjectAtPath(sourceRoot, parentPath, serializerOptions, out object? sourceParent) || sourceParent is null) + if (sourceRoot is null || !TryGetSourceObjectAtPath(sourceRoot, serializedRoot, parentPath, serializerOptions, out object? sourceParent) || sourceParent is null) { return false; } @@ -1378,8 +1375,7 @@ private static bool TryGetSourceValue( return false; } - value = SerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo); - return sourceValue is null || value is not null; + return TrySerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo, out value); } private static bool WasOmittedBySupportedIgnoreCondition( @@ -1394,6 +1390,15 @@ private static bool WasOmittedBySupportedIgnoreCondition( .FirstOrDefault() : null; + if (ignoreAttribute is null && propertyInfo is { Set: null } && + (propertyInfo.AttributeProvider is FieldInfo + ? serializerOptions.IgnoreReadOnlyFields + : serializerOptions.IgnoreReadOnlyProperties && + serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary))) + { + return false; + } + if (propertyInfo?.ShouldSerialize is { } shouldSerialize && !IsJsonIgnoreConditionPredicate(shouldSerialize) && !shouldSerialize(sourceParent, value)) @@ -1454,6 +1459,7 @@ private static object CreateDefaultValue(Type valueType) private static bool TryGetSourceObjectAtPath( object sourceRoot, + JsonNode serializedRoot, string path, JsonSerializerOptions serializerOptions, out object? value) @@ -1470,9 +1476,12 @@ private static bool TryGetSourceObjectAtPath( return false; } + string currentPath = string.Empty; foreach (string rawSegment in path.Substring(1).Split('/')) { string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + TryGetNodeAtPath(serializedRoot, currentPath, out JsonNode? serializedParent); + currentPath = AppendJsonPointer(currentPath, segment); if (value is null) { return false; @@ -1516,7 +1525,8 @@ private static bool TryGetSourceObjectAtPath( return false; } - if (value is IDictionary dictionary && TryGetDictionaryValue(dictionary, segment, serializerOptions, out object? dictionaryValue)) + if (value is IDictionary dictionary && serializedParent is JsonObject serializedDictionary && + TryGetDictionaryValue(dictionary, serializedDictionary, segment, serializerOptions, out object? dictionaryValue)) { value = dictionaryValue; continue; @@ -1556,6 +1566,7 @@ private static bool TryGetSourceObjectAtPath( private static bool TryGetDictionaryValue( IDictionary dictionary, + JsonObject serializedDictionary, string propertyName, JsonSerializerOptions serializerOptions, out object? value) @@ -1571,35 +1582,23 @@ private static bool TryGetDictionaryValue( return false; } - if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary || - dictionaryTypeInfo.KeyType is null || - dictionaryTypeInfo.ElementType is not { } valueType || - dictionaryTypeInfo.CreateObject?.Invoke() is not IDictionary singleEntry) + if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary) { return false; } + using IEnumerator> properties = serializedDictionary.GetEnumerator(); foreach (DictionaryEntry entry in dictionary) { - if (entry.Key is null) + if (!properties.MoveNext()) { - continue; + return false; } - try - { - singleEntry.Clear(); - singleEntry.Add(entry.Key, entry.Value); - JsonElement serialized = JsonSerializer.SerializeToElement(singleEntry, dictionaryTypeInfo); - if (serialized.ValueKind == JsonValueKind.Object && serialized.TryGetProperty(propertyName, out _)) - { - value = entry.Value; - return true; - } - } - catch (Exception exception) when (exception is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) + if (string.Equals(properties.Current.Key, propertyName, StringComparison.Ordinal)) { - return false; + value = entry.Value; + return true; } } @@ -1666,22 +1665,14 @@ private static bool TryGetSourceMember( return false; } - private static JsonNode? SerializeSourceValue( + private static bool TrySerializeSourceValue( object? value, Type? declaredType, JsonSerializerOptions serializerOptions, - JsonPropertyInfo? propertyInfo) + JsonPropertyInfo? propertyInfo, + out JsonNode? serializedValue) { - if (value is JsonNode jsonNode) - { - return jsonNode.DeepClone(); - } - - if (value is JsonElement jsonElement) - { - return JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement); - } - + serializedValue = null; Type valueType = declaredType ?? value?.GetType() ?? typeof(object); try { @@ -1703,11 +1694,12 @@ private static bool TryGetSourceMember( JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); - return JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); + serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); + return true; } catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) { - return null; + return false; } } @@ -1752,7 +1744,7 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen if (!pointer.StartsWith("/", StringComparison.Ordinal)) { - return false; + return TryFindSchemaAnchor(schemaRoot, pointer, out resolvedSchema); } JsonNode? current = schemaRoot; @@ -1777,6 +1769,60 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen return true; } + private static bool TryFindSchemaAnchor(JsonNode? node, string anchor, out JsonNode? resolvedSchema, bool dynamicOnly = false, bool isResourceRoot = true) + { + resolvedSchema = null; + if (node is not JsonObject schemaObject || (!isResourceRoot && schemaObject.ContainsKey("$id"))) + { + return false; + } + + if ((!dynamicOnly && schemaObject["$anchor"]?.GetValue() == anchor) || + schemaObject["$dynamicAnchor"]?.GetValue() == anchor) + { + resolvedSchema = node; + return true; + } + + foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" }) + { + if (schemaObject[keyword] is JsonObject schemas) + { + foreach (KeyValuePair schema in schemas) + { + if (TryFindSchemaAnchor(schema.Value, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (schemaObject[keyword] is JsonArray schemas) + { + foreach (JsonNode? schema in schemas) + { + if (TryFindSchemaAnchor(schema, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not" }) + { + if (TryFindSchemaAnchor(schemaObject[keyword], anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + + return false; + } + private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) { List contentList = []; diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 4a8d2098d..302444e83 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,12 +1,17 @@ using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Collections.ObjectModel; using System.IO.Pipelines; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; Pipe clientToServerPipe = new(), serverToClientPipe = new(); @@ -20,9 +25,25 @@ entry.Meta["example.com/aot"]!["values"]!.AsArray().Clear(); var services = new ServiceCollection(); +JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions) +{ + TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver), +}; +McpServerTool structuredTool = McpServerTool.Create( + () => new AotStructuredOutput(), + new() + { + Name = "RequiredDefaults", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse(""" + {"type":"object","required":["id","note","value","items"],"properties":{"id":{"type":"null"},"note":{"type":"null"},"value":{"const":"converted"},"items":{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"const":0}}}}}} + """).RootElement, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() + .WithTools([structuredTool]) .WithMcpApps() .WithSkills(catalog) .WithResources(authoredSkill.Resources); @@ -55,6 +76,16 @@ throw new Exception($"Unexpected result: {result}"); } +var structuredResult = await client.CallToolAsync("RequiredDefaults"); +if (structuredResult.StructuredContent is not { } structured || + structured.GetProperty("id").ValueKind != JsonValueKind.Null || + structured.GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("value").GetString() != "converted" || + structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0) +{ + throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}"); +} + // List, get, and verify a skill. var skills = await client.ListSkillsAsync(); if (skills.Count != 1 || skills[0].Name != "aot") @@ -102,3 +133,37 @@ internal sealed class AotTools [McpAppUi(ResourceUri = "ui://aot/echo")] public static string Echo(string arg) => $"Echo: {arg}"; } + +internal sealed class AotStructuredOutput +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RequestId Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(AotElementConverter))] + public JsonElement Value { get; init; } + + public ReadOnlyDictionary Items { get; init; } = + new(new Dictionary { [42] = default }); +} + +internal struct AotStructuredChild +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } +} + +internal sealed class AotElementConverter : JsonConverter +{ + public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) => + writer.WriteStringValue("converted"); +} + +[JsonSerializable(typeof(AotStructuredOutput))] +internal partial class AotJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2c3210d56..ac62c4c5f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Moq; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Reflection; using System.Runtime.InteropServices; @@ -1556,6 +1557,265 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesPropertyNumbe AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RefreshesChangedSchema() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object"}"""); + await InvokeStructuredOutputTestTool(tool); + + tool.ProtocolTool.OutputSchema = JsonDocument.Parse( + """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result); + + tool.ProtocolTool.OutputSchema = JsonDocument.Parse( + """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","properties":{"note":false}}""").RootElement; + result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(1, 3)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsContainsMembers(int minimum, int maximum) + { + string schema = $$$""" + {"type":"array","contains":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } },"minContains":{{{minimum}}},"maxContains":{{{maximum}}}} + """; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 3).Select(index => new ExplicitSchemaOutput { Id = index.ToString() }).ToArray(), + schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.InRange(result.EnumerateArray().Count(item => item.TryGetProperty("note", out _)), minimum, maximum); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterProducedNull() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + """{"type":"object","required":["id"],"properties":{"id":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversReadOnlyDictionaryValue(bool customKeys) + { + var output = new ReadOnlyDictionary( + new Dictionary + { + [1] = new() { Count = 5, Status = ExplicitSchemaStatus.Ready }, + [42] = new() { Count = 0, Status = ExplicitSchemaStatus.Ready }, + }); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new JsonStringEnumConverter(allowIntegerValues: false)); + if (customKeys) + { + options.Converters.Add(new FormattedIntegerKeyConverter()); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + output, + """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""", + options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(5, result.GetProperty(customKeys ? "key-1" : "1").GetProperty("count").GetInt32()); + Assert.Equal(0, result.GetProperty(customKeys ? "key-42" : "42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesJsonElementMemberConverter() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultJsonElementOutput(), + """{"type":"object","required":["value"],"properties":{"value":{"const":"converted"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal("converted", result.GetProperty("value").GetString()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesNestedRepairs() + { + string properties = string.Join(",", Enumerable.Range(0, 24).Select(index => + $$$""" "child{{{index}}}":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } }""")); + string required = string.Join(",", Enumerable.Range(0, 24).Select(index => $"\"child{index}\"")); + McpServerTool tool = CreateStructuredOutputTestTool( + new ManyNestedDefaultOutputs(), + $$"""{"type":"object","properties":{ {{properties}} },"required":[{{required}}]}""", + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + for (int i = 0; i < 24; i++) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty($"child{i}").GetProperty("note").ValueKind); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesBranchCandidateGroups(int tag) + { + string branches = string.Join(",", Enumerable.Range(0, 8).Select(index => + $$$""" + {"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 3}}}":{"type":"null"},"note{{{index * 3 + 1}}}":{"type":"null"},"note{{{index * 3 + 2}}}":{"type":"null"}},"required":["tag","note{{{index * 3}}}","note{{{index * 3 + 1}}}","note{{{index * 3 + 2}}}"],"additionalProperties":false} + """)); + McpServerTool tool = CreateStructuredOutputTestTool( + new ManyBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + $$"""{"type":"object","oneOf":[{{branches}}]}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(4, result.EnumerateObject().Count()); + for (int i = 0; i < 3; i++) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty($"note{tag * 3 + i}").ValueKind); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsUnevaluatedMembers(bool isArray) + { + object output = isArray + ? new[] { new ExplicitSchemaOutput { Id = "42" }, new ExplicitSchemaOutput { Id = "43" } } + : new Dictionary { ["evaluated"] = new() { Id = "42" }, ["other"] = new() { Id = "43" } }; + string schema = isArray + ? """{"type":"array","prefixItems":[{"type":"object","properties":{"note":false}}],"unevaluatedItems":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}""" + : """{"type":"object","properties":{"evaluated":{"type":"object","properties":{"note":false}}},"unevaluatedProperties":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement evaluated = isArray ? result[0] : result.GetProperty("evaluated"); + JsonElement unevaluated = isArray ? result[1] : result.GetProperty("other"); + Assert.False(evaluated.TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, unevaluated.GetProperty("note").ValueKind); + } + + [Theory] + [InlineData("$ref", "$anchor")] + [InlineData("$ref", "$dynamicAnchor")] + [InlineData("$dynamicRef", "$dynamicAnchor")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesNamedAnchor(string referenceKeyword, string anchorKeyword) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitSchemaOutput { Id = "42" }, + $$$"""{"type":"object","$defs":{"entry":{"{{{anchorKeyword}}}":"entry","type":"object","required":["note"]}},"{{{referenceKeyword}}}":"#entry"}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsAnchorResourceBoundaries(bool nested) + { + string schema = nested + ? """{"$id":"https://example.test/root","type":"object","properties":{"child":{"$id":"child","$defs":{"entry":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}},"$defs":{"entry":{"$anchor":"entry","required":["other"]}}}""" + : """{"$id":"https://example.test/root","type":"object","$defs":{"foreign":{"$id":"foreign","$anchor":"entry","required":["other"]},"local":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}"""; + object output = nested + ? new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42" } } + : new ExplicitSchemaOutput { Id = "42" }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, (nested ? result.GetProperty("child") : result).GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorInCallingScope() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DynamicReferenceOutput { Child = new() { Id = "42" } }, + """ + { + "$id":"https://example.test/root", + "$dynamicAnchor":"entry", + "$ref":"#/$defs/base", + "required":["note"], + "$defs":{ + "base":{ + "$id":"base", + "$dynamicAnchor":"entry", + "type":"object", + "properties":{"child":{"$dynamicRef":"#entry"}} + } + } + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverFailedNullConversion() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RejectedNullOutput(), + """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclusions(bool isField) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.IgnoreReadOnlyProperties = !isField; + options.IgnoreReadOnlyFields = isField; + options.IncludeFields = isField; + object output = isField ? new ReadOnlyFieldOutput() : new ReadOnlyPropertyOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","required":["count"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("count", out _)); + } + + private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) => + new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = condition, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + + private static McpServerTool CreateStructuredOutputTestTool(T value, string schema, JsonSerializerOptions? options = null) => + McpServerTool.Create(() => value, new() + { + Name = "tool", + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(schema).RootElement, + SerializerOptions = options ?? CreateStructuredOutputTestOptions(), + }); + + private static async Task InvokeStructuredOutputTestTool(McpServerTool tool) + { + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + Assert.NotEqual(true, result.IsError); + Assert.NotNull(result.StructuredContent); + return result.StructuredContent.Value; + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -2143,7 +2403,7 @@ public object InstanceMethod() private static void AssertMatchesJsonSchema(JsonElement schemaDoc, JsonElement? value) { - JsonSchema schema = JsonSerializer.Deserialize(schemaDoc, JsonContext2.Default.JsonSchema)!; + JsonSchema schema = JsonSchema.Build(schemaDoc, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; EvaluationResults results = schema.Evaluate(value!.Value, options); if (!results.IsValid) @@ -2207,6 +2467,135 @@ private sealed class ExplicitSchemaOutputWithMemberDefault public int Count { get; init; } } + private sealed class DefaultRequestIdOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RequestId Id { get; init; } + } + + private sealed class DynamicReferenceOutput + { + public ExplicitSchemaOutput? Child { get; init; } + + public string? Note { get; init; } + } + + private sealed class FormattedIntegerKeyConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetInt32(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteNumberValue(value); + + public override int ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + int.Parse(reader.GetString()!.Substring(4), System.Globalization.CultureInfo.InvariantCulture); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WritePropertyName("key-" + value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private sealed class DefaultJsonElementOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(DefaultJsonElementConverter))] + public JsonElement Value { get; init; } + } + + private sealed class DefaultJsonElementConverter : JsonConverter + { + public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) => + writer.WriteStringValue("converted"); + } + + private sealed class RejectedNullOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(RejectedNullConverter))] + public string? Note { get; init; } + } + + private sealed class RejectedNullConverter : JsonConverter + { + public override bool HandleNull => true; + + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => + throw new JsonException("Null cannot be serialized."); + } + + private sealed class ReadOnlyPropertyOutput + { + public int Count => 0; + } + + private sealed class ReadOnlyFieldOutput + { + public readonly int Count = 0; + } + + private sealed class ManyNestedDefaultOutputs + { + public NestedDefaultedStructuredChild Child0 { get; init; } + public NestedDefaultedStructuredChild Child1 { get; init; } + public NestedDefaultedStructuredChild Child2 { get; init; } + public NestedDefaultedStructuredChild Child3 { get; init; } + public NestedDefaultedStructuredChild Child4 { get; init; } + public NestedDefaultedStructuredChild Child5 { get; init; } + public NestedDefaultedStructuredChild Child6 { get; init; } + public NestedDefaultedStructuredChild Child7 { get; init; } + public NestedDefaultedStructuredChild Child8 { get; init; } + public NestedDefaultedStructuredChild Child9 { get; init; } + public NestedDefaultedStructuredChild Child10 { get; init; } + public NestedDefaultedStructuredChild Child11 { get; init; } + public NestedDefaultedStructuredChild Child12 { get; init; } + public NestedDefaultedStructuredChild Child13 { get; init; } + public NestedDefaultedStructuredChild Child14 { get; init; } + public NestedDefaultedStructuredChild Child15 { get; init; } + public NestedDefaultedStructuredChild Child16 { get; init; } + public NestedDefaultedStructuredChild Child17 { get; init; } + public NestedDefaultedStructuredChild Child18 { get; init; } + public NestedDefaultedStructuredChild Child19 { get; init; } + public NestedDefaultedStructuredChild Child20 { get; init; } + public NestedDefaultedStructuredChild Child21 { get; init; } + public NestedDefaultedStructuredChild Child22 { get; init; } + public NestedDefaultedStructuredChild Child23 { get; init; } + } + + private sealed class ManyBranchStructuredOutput + { + public required string Tag { get; init; } + public string? Note0 { get; init; } + public string? Note1 { get; init; } + public string? Note2 { get; init; } + public string? Note3 { get; init; } + public string? Note4 { get; init; } + public string? Note5 { get; init; } + public string? Note6 { get; init; } + public string? Note7 { get; init; } + public string? Note8 { get; init; } + public string? Note9 { get; init; } + public string? Note10 { get; init; } + public string? Note11 { get; init; } + public string? Note12 { get; init; } + public string? Note13 { get; init; } + public string? Note14 { get; init; } + public string? Note15 { get; init; } + public string? Note16 { get; init; } + public string? Note17 { get; init; } + public string? Note18 { get; init; } + public string? Note19 { get; init; } + public string? Note20 { get; init; } + public string? Note21 { get; init; } + public string? Note22 { get; init; } + public string? Note23 { get; init; } + } + private sealed class ExplicitSchemaOutputWithMemberConverter { public required string Id { get; init; } From 4ca13cf1563d102ba46d381c26804d6a74a80f35 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Tue, 27 Oct 2026 16:43:14 -0700 Subject: [PATCH 12/30] Fix composed structured output recovery and embedded references --- .../Server/AIFunctionMcpServerTool.cs | 351 +++++++++++++++--- .../Program.cs | 79 +++- .../Server/McpServerToolTests.cs | 297 ++++++++++++++- 3 files changed, 673 insertions(+), 54 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index a1b2fddb2..42395c284 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -795,33 +795,38 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) JsonNode schemaNode, JsonSchema schema, object? originalResult, - JsonSerializerOptions serializerOptions) + JsonSerializerOptions serializerOptions, + int remainingDepth = 32, + JsonNode? schemaDocument = null, + SchemaReferenceResolver? references = null) { // An explicit schema may deliberately omit optional nulls. Preserve that valid output // before looking for missing required members. - if (IsSchemaValid(schema, resultNode)) + if (remainingDepth <= 0 || IsSchemaValid(schema, resultNode)) { return null; } - const int maxRepairDepth = 32; const int maxCandidateSetEvaluations = 256; Queue pending = new(); Queue fallback = new(); HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; + schemaDocument ??= schemaNode; + references ??= new(schemaDocument); + Dictionary branchSchemas = new(); void EnqueueRepairs(JsonNode instance, int depth) { - if (depth >= maxRepairDepth) + if (depth >= remainingDepth) { return; } Dictionary contexts = new(StringComparer.Ordinal); - List> groups = []; + List groups = []; CollectSchemaContexts( - instance, schemaNode, schemaNode, string.Empty, contexts, - new HashSet(StringComparer.Ordinal), groups); + instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, + new HashSet(StringComparer.Ordinal), groups, references); List candidates = CreateRepairCandidates( instance, resultNodeIncludingDefaults, originalResult, serializerOptions, contexts); if (candidates.Count == 0) @@ -829,12 +834,106 @@ void EnqueueRepairs(JsonNode instance, int depth) return; } + HashSet grouped = new(groups.SelectMany(static group => group.Keys), StringComparer.Ordinal); + JsonNode combinedResult = instance.DeepClone(); + bool hasCombinedRepairs = false; + foreach (RepairCandidate candidate in candidates.Where(candidate => !grouped.Contains(candidate.Key))) + { + ApplyRepairCandidate(combinedResult, candidate); + hasCombinedRepairs = true; + } + + foreach (RepairCandidateGroup group in groups) + { + if (!TryGetNodeAtPath(combinedResult, group.InstancePath, out JsonNode? currentBranch) || currentBranch is null) + { + continue; + } + + if (!branchSchemas.TryGetValue(group.Schema, out JsonSchema? branchSchema)) + { + JsonNode document = schemaDocument.DeepClone(); + if (document is JsonObject documentObject) + { + documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri; + } + + JsonObject branchDocument = new() + { + ["$ref"] = references.GetSchemaUri(group.Schema).AbsoluteUri, + ["$defs"] = new JsonObject { ["document"] = document }, + }; + TryBuildJsonSchema(branchDocument, out branchSchema); + branchSchemas.Add(group.Schema, branchSchema); + } + + if (branchSchema is null) + { + continue; + } + + // Compose independently valid subtree repairs before searching whole-document subsets. + foreach (bool includeDescendants in new[] { false, true }) + { + List branchCandidates = candidates.Where(candidate => + group.Keys.Contains(candidate.Key) && + (includeDescendants || candidate.ParentPath == group.InstancePath)).ToList(); + JsonNode branchResult = currentBranch.DeepClone(); + foreach (RepairCandidate candidate in branchCandidates) + { + ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }); + } + + bool branchIsValid = IsSchemaValid(branchSchema, branchResult); + if (!branchIsValid && includeDescendants && branchCandidates.Count > 0 && depth + 1 < remainingDepth) + { + TryGetNodeAtPath(resultNodeIncludingDefaults, group.InstancePath, out JsonNode? completeBranch); + object? branchSource = null; + if (originalResult is not null) + { + TryGetSourceObjectAtPath(originalResult, instance, group.InstancePath, serializerOptions, out branchSource); + } + + if (RepairStructuredResult( + branchResult, completeBranch, group.Schema, branchSchema, branchSource, + serializerOptions, remainingDepth - depth - 1, schemaDocument, references) is JsonNode repairedBranch) + { + branchResult = repairedBranch; + branchIsValid = true; + } + } + + if (branchIsValid) + { + if (group.InstancePath.Length == 0) + { + combinedResult = branchResult; + } + else if (currentBranch.Parent is JsonArray parentArray) + { + parentArray[parentArray.IndexOf(currentBranch)] = branchResult; + } + else if (currentBranch.Parent is JsonObject parentObject) + { + parentObject[parentObject.First(property => ReferenceEquals(property.Value, currentBranch)).Key] = branchResult; + } + + hasCombinedRepairs = true; + break; + } + } + } + + if (hasCombinedRepairs) + { + pending.Enqueue(new(combinedResult, [], depth + 1)); + } + pending.Enqueue(new(instance, candidates, depth + 1)); - HashSet grouped = new(groups.SelectMany(static group => group), StringComparer.Ordinal); - foreach (HashSet group in groups) + foreach (RepairCandidateGroup group in groups) { List branchCandidates = candidates.Where(candidate => - group.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList(); + group.Keys.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList(); if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count && pending.Count < maxCandidateSetEvaluations) { @@ -891,7 +990,7 @@ private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? sche schema = JsonSchema.Build(schemaElement, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); return true; } - catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) { schema = null; return false; @@ -927,7 +1026,7 @@ private static bool IsSchemaValid(JsonSchema schema, JsonNode instance) JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); return schema.Evaluate(instanceElement).IsValid; } - catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) { return false; } @@ -1008,6 +1107,22 @@ private sealed record RepairCandidate(string ParentPath, string PropertyName, Js private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); + private sealed record RepairCandidateGroup(HashSet Keys, string InstancePath, JsonNode Schema); + + private static string GetSchemaPointer(JsonNode schema, JsonNode resource) + { + string pointer = string.Empty; + for (JsonNode node = schema; !ReferenceEquals(node, resource) && node.Parent is JsonNode parent; node = parent) + { + string segment = parent is JsonArray array + ? array.IndexOf(node).ToString(CultureInfo.InvariantCulture) + : ((JsonObject)parent).First(property => ReferenceEquals(property.Value, node)).Key; + pointer = AppendJsonPointer(string.Empty, segment) + pointer; + } + + return pointer; + } + private static void CollectSchemaContexts( JsonNode? instanceNode, JsonNode? schemaNode, @@ -1015,7 +1130,8 @@ private static void CollectSchemaContexts( string instancePath, Dictionary contexts, HashSet activeReferences, - List> groups, + List groups, + SchemaReferenceResolver references, List? dynamicScope = null) { if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) @@ -1042,23 +1158,26 @@ private static void CollectSchemaContexts( refText is not null && activeReferences.Add(schemaRoot.GetPath() + "|" + refText + "|" + instancePath)) { - if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + if (references.TryResolve(schemaRoot, refText, out JsonNode? referencedSchema, out JsonNode referencedResource)) { if (referenceKeyword == "$dynamicRef" && referencedSchema is JsonObject referencedObject && referencedObject["$dynamicAnchor"]?.GetValue() is string anchor && - string.Equals(refText, "#" + anchor, StringComparison.Ordinal)) + refText.IndexOf('#') is int fragmentStart && fragmentStart >= 0 && + string.Equals(Uri.UnescapeDataString(refText[(fragmentStart + 1)..]), anchor, StringComparison.Ordinal)) { foreach (JsonNode resource in dynamicScope) { if (TryFindSchemaAnchor(resource, anchor, out JsonNode? dynamicSchema, dynamicOnly: true)) { referencedSchema = dynamicSchema; + referencedResource = resource; break; } } } - CollectSchemaContexts(instanceNode, referencedSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + CollectSchemaContexts(instanceNode, referencedSchema, referencedResource, instancePath, contexts, activeReferences, groups, references, + ReferenceEquals(schemaRoot, referencedResource) ? dynamicScope : [.. dynamicScope, referencedResource]); } activeReferences.Remove(schemaRoot.GetPath() + "|" + refText + "|" + instancePath); @@ -1100,7 +1219,7 @@ refText is not null && AppendJsonPointer(instancePath, property.Key), contexts, new HashSet(activeReferences, StringComparer.Ordinal), - groups, dynamicScope); + groups, references, dynamicScope); } } @@ -1128,7 +1247,7 @@ refText is not null && AppendJsonPointer(instancePath, property.Key), contexts, new HashSet(activeReferences, StringComparer.Ordinal), - groups, dynamicScope); + groups, references, dynamicScope); } } } @@ -1142,14 +1261,14 @@ refText is not null && AppendJsonPointer(instancePath, property.Key), contexts, new HashSet(activeReferences, StringComparer.Ordinal), - groups, dynamicScope); + groups, references, dynamicScope); } if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema) { CollectSchemaBranch( property.Value, unevaluatedPropertySchema, schemaRoot, - AppendJsonPointer(instancePath, property.Key), contexts, activeReferences, groups, dynamicScope); + AppendJsonPointer(instancePath, property.Key), contexts, activeReferences, groups, references, dynamicScope); } } } @@ -1170,7 +1289,7 @@ refText is not null && AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), contexts, new HashSet(activeReferences, StringComparer.Ordinal), - groups, dynamicScope); + groups, references, dynamicScope); } foreach (string keyword in new[] { "contains", "unevaluatedItems" }) @@ -1180,7 +1299,7 @@ refText is not null && CollectSchemaBranch( arrayInstance[i], subschema, schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), - contexts, activeReferences, groups, dynamicScope); + contexts, activeReferences, groups, references, dynamicScope); } } } @@ -1218,7 +1337,7 @@ refText is not null && instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal), - groups, dynamicScope); + groups, references, dynamicScope); } } } @@ -1238,11 +1357,11 @@ refText is not null && { CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, contexts, - new HashSet(activeReferences, StringComparer.Ordinal), groups, dynamicScope); + new HashSet(activeReferences, StringComparer.Ordinal), groups, references, dynamicScope); } else { - CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); } } } @@ -1252,12 +1371,12 @@ refText is not null && { if (schemaObject["then"] is JsonNode thenSchema) { - CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); } if (schemaObject["else"] is JsonNode elseSchema) { - CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, activeReferences, groups, dynamicScope); + CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); } } } @@ -1269,13 +1388,14 @@ private static void CollectSchemaBranch( string instancePath, Dictionary contexts, HashSet activeReferences, - List> groups, + List groups, + SchemaReferenceResolver references, List dynamicScope) { Dictionary branchContexts = new(StringComparer.Ordinal); CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, branchContexts, - new HashSet(activeReferences, StringComparer.Ordinal), groups, dynamicScope); + new HashSet(activeReferences, StringComparer.Ordinal), groups, references, dynamicScope); HashSet group = new(StringComparer.Ordinal); foreach (KeyValuePair entry in branchContexts) { @@ -1288,7 +1408,7 @@ private static void CollectSchemaBranch( if (group.Count > 0) { - groups.Add(group); + groups.Add(new(group, instancePath, branch)); } } @@ -1384,17 +1504,9 @@ private static bool WasOmittedBySupportedIgnoreCondition( JsonPropertyInfo? propertyInfo, JsonSerializerOptions serializerOptions) { - JsonIgnoreAttribute? ignoreAttribute = propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider - ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true) - .OfType() - .FirstOrDefault() - : null; + JsonIgnoreAttribute? ignoreAttribute = GetIgnoreAttribute(propertyInfo); - if (ignoreAttribute is null && propertyInfo is { Set: null } && - (propertyInfo.AttributeProvider is FieldInfo - ? serializerOptions.IgnoreReadOnlyFields - : serializerOptions.IgnoreReadOnlyProperties && - serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary))) + if (propertyInfo is not null && IsExcludedReadOnlyMember(propertyInfo, serializerOptions)) { return false; } @@ -1417,6 +1529,20 @@ propertyInfo is not null && }; } + private static JsonIgnoreAttribute? GetIgnoreAttribute(JsonPropertyInfo? propertyInfo) => + propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider + ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true) + .OfType() + .FirstOrDefault() + : null; + + private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) => + GetIgnoreAttribute(propertyInfo) is null && propertyInfo.Set is null && + (propertyInfo.AttributeProvider is FieldInfo + ? serializerOptions.IgnoreReadOnlyFields + : serializerOptions.IgnoreReadOnlyProperties && + serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary)); + // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) { @@ -1525,8 +1651,8 @@ private static bool TryGetSourceObjectAtPath( return false; } - if (value is IDictionary dictionary && serializedParent is JsonObject serializedDictionary && - TryGetDictionaryValue(dictionary, serializedDictionary, segment, serializerOptions, out object? dictionaryValue)) + if (serializedParent is JsonObject serializedDictionary && + TryGetDictionaryValue(value, serializedDictionary, segment, serializerOptions, out object? dictionaryValue)) { value = dictionaryValue; continue; @@ -1565,7 +1691,7 @@ private static bool TryGetSourceObjectAtPath( } private static bool TryGetDictionaryValue( - IDictionary dictionary, + object dictionary, JsonObject serializedDictionary, string propertyName, JsonSerializerOptions serializerOptions, @@ -1582,13 +1708,13 @@ private static bool TryGetDictionaryValue( return false; } - if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary) + if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary || dictionary is not IEnumerable entries) { return false; } using IEnumerator> properties = serializedDictionary.GetEnumerator(); - foreach (DictionaryEntry entry in dictionary) + foreach (object entry in entries) { if (!properties.MoveNext()) { @@ -1597,14 +1723,39 @@ private static bool TryGetDictionaryValue( if (string.Equals(properties.Current.Key, propertyName, StringComparison.Ordinal)) { - value = entry.Value; - return true; + return TryGetDictionaryEntryValue(entry, out value); } } return false; } +#if !NETSTANDARD2_0 + [System.Diagnostics.CodeAnalysis.DynamicDependency( + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties, typeof(KeyValuePair<,>))] +#endif + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2075", + Justification = "The reflected property belongs to KeyValuePair<,>, whose public properties are explicitly preserved.")] + private static bool TryGetDictionaryEntryValue(object entry, out object? value) + { + if (entry is DictionaryEntry dictionaryEntry) + { + value = dictionaryEntry.Value; + return true; + } + + Type type = entry.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + { + value = type.GetProperty(nameof(KeyValuePair.Value))!.GetValue(entry); + return true; + } + + value = null; + return false; + } + private static bool TryGetSourceMember( object source, string propertyName, @@ -1648,7 +1799,8 @@ private static bool TryGetSourceMember( JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(source.GetType()); foreach (JsonPropertyInfo property in typeInfo.Properties) { - if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null) + if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null && + !IsExcludedReadOnlyMember(property, serializerOptions)) { value = property.Get(source); declaredType = property.PropertyType; @@ -1727,6 +1879,109 @@ private static bool TrySerializeSourceValue( return null; } + private sealed class SchemaReferenceResolver + { + private readonly Dictionary _resources = new(); + private readonly Dictionary _resourceIds = new(); + + internal SchemaReferenceResolver(JsonNode schema) + { + Register(schema, new Uri("https://mcp.invalid/schema"), isRoot: true); + } + + internal Uri GetSchemaUri(JsonNode schema) + { + JsonNode resource = GetResourceRoot(schema); + string pointer = GetSchemaPointer(schema, resource); + return pointer.Length == 0 ? _resourceIds[resource] : new Uri(_resourceIds[resource], "#" + Uri.EscapeDataString(pointer)); + } + + internal JsonNode GetResourceRoot(JsonNode schema) + { + JsonNode resource = schema; + while (!_resourceIds.ContainsKey(resource) && resource.Parent is JsonNode parent) + { + resource = parent; + } + + return resource; + } + + private void Register(JsonNode? schema, Uri baseUri, bool isRoot = false) + { + if (schema is not JsonObject schemaObject) + { + return; + } + + bool isResource = isRoot; + if (schemaObject["$id"] is JsonValue idValue && idValue.TryGetValue(out string? id) && + Uri.TryCreate(baseUri, id, out Uri? resourceUri)) + { + baseUri = resourceUri; + isResource = true; + } + + if (isResource) + { + _resourceIds[schema] = baseUri; + _resources[new UriBuilder(baseUri) { Fragment = string.Empty }.Uri] = schema; + } + + foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" }) + { + if (schemaObject[keyword] is JsonObject schemas) + { + foreach (KeyValuePair child in schemas) + { + Register(child.Value, baseUri); + } + } + } + + foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (schemaObject[keyword] is JsonArray schemas) + { + foreach (JsonNode? child in schemas) + { + Register(child, baseUri); + } + } + } + + foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not" }) + { + Register(schemaObject[keyword], baseUri); + } + } + + internal bool TryResolve(JsonNode currentResource, string reference, out JsonNode? schema, out JsonNode resource) + { + schema = null; + resource = currentResource; + if (!_resourceIds.TryGetValue(currentResource, out Uri? baseUri) || + !Uri.TryCreate(baseUri, reference, out Uri? targetUri) || + !_resources.TryGetValue(new UriBuilder(targetUri) { Fragment = string.Empty }.Uri, out JsonNode? targetResource) || + !TryResolveLocalReference(targetResource, targetUri.Fragment.Length == 0 ? "#" : targetUri.Fragment, out schema)) + { + return false; + } + + resource = targetResource; + for (JsonNode? node = schema; node is not null; node = node.Parent) + { + if (_resourceIds.ContainsKey(node)) + { + resource = node; + break; + } + } + + return true; + } + } + private static bool TryResolveLocalReference(JsonNode schemaRoot, string reference, out JsonNode? resolvedSchema) { resolvedSchema = null; diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 302444e83..f39e11658 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -7,6 +7,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Collections.ObjectModel; +using System.Dynamic; using System.IO.Pipelines; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,6 +28,7 @@ var services = new ServiceCollection(); JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions) { + IgnoreReadOnlyProperties = true, TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver), }; McpServerTool structuredTool = McpServerTool.Create( @@ -37,7 +39,23 @@ UseStructuredContent = true, SerializerOptions = structuredOptions, OutputSchema = JsonDocument.Parse(""" - {"type":"object","required":["id","note","value","items"],"properties":{"id":{"type":"null"},"note":{"type":"null"},"value":{"const":"converted"},"items":{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"const":0}}}}}} + { + "$id":"https://example.test/aot", + "type":"object", + "required":["id","note","value","items","genericItems","dynamicItems","unions"], + "anyOf":[{"required":["count"]},{"required":["note"]}], + "$defs":{"child":{"$id":"child","type":"object","required":["count"],"properties":{"count":{"const":0}}}}, + "properties":{ + "id":{"type":"null"},"note":{"type":"null"},"value":{"const":"converted"}, + "items":{"type":"object","additionalProperties":{"$ref":"child"}}, + "genericItems":{"type":"object","additionalProperties":{"$ref":"child"}}, + "dynamicItems":{"type":"object","additionalProperties":{"$ref":"child"}}, + "unions":{"type":"array","items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} + ]}} + } + } """).RootElement, }); services.AddMcpServer() @@ -81,11 +99,23 @@ structured.GetProperty("id").ValueKind != JsonValueKind.Null || structured.GetProperty("note").ValueKind != JsonValueKind.Null || structured.GetProperty("value").GetString() != "converted" || - structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0) + structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("genericItems").GetProperty("42").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("dynamicItems").GetProperty("entry").GetProperty("count").GetInt32() != 0 || + structured.TryGetProperty("count", out _)) { throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}"); } +foreach (JsonElement item in structured.GetProperty("unions").EnumerateArray()) +{ + if (item.GetProperty("child0").GetProperty("note").ValueKind != JsonValueKind.Null || + item.TryGetProperty("child1", out _)) + { + throw new Exception($"Unexpected union output: {item}"); + } +} + // List, get, and verify a skill. var skills = await client.ListSkillsAsync(); if (skills.Count != 1 || skills[0].Name != "aot") @@ -136,6 +166,8 @@ internal sealed class AotTools internal sealed class AotStructuredOutput { + public int Count => throw new InvalidOperationException("Excluded getter was invoked."); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public RequestId Id { get; init; } @@ -148,6 +180,49 @@ internal sealed class AotStructuredOutput public ReadOnlyDictionary Items { get; init; } = new(new Dictionary { [42] = default }); + + public AotGenericOnlyDictionary GenericItems { get; init; } = + new(new Dictionary { [42] = default }); + + public ExpandoObject DynamicItems { get; init; } = CreateDynamicItems(); + + public AotUnionOutput[] Unions { get; init; } = Enumerable.Range(0, 5).Select(_ => new AotUnionOutput()).ToArray(); + + private static ExpandoObject CreateDynamicItems() + { + ExpandoObject items = new(); + ((IDictionary)items)["entry"] = default(AotStructuredChild); + return items; + } +} + +internal sealed class AotUnionOutput +{ + public string Tag { get; init; } = "0"; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotNullableChild Child0 { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotNullableChild Child1 { get; init; } +} + +internal struct AotNullableChild +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } +} + +internal sealed class AotGenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary +{ + public TValue this[TKey key] => entries[key]; + public IEnumerable Keys => entries.Keys; + public IEnumerable Values => entries.Values; + public int Count => entries.Count; + public bool ContainsKey(TKey key) => entries.ContainsKey(key); + public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!); + public IEnumerator> GetEnumerator() => entries.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } internal struct AotStructuredChild diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index ac62c4c5f..b9d39587d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -6,6 +6,7 @@ using Moq; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Dynamic; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; @@ -1740,15 +1741,19 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsAnchorResource Assert.Equal(JsonValueKind.Null, (nested ? result.GetProperty("child") : result).GetProperty("note").ValueKind); } - [Fact] - public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorInCallingScope() + [Theory] + [InlineData("#entry")] + [InlineData("base#entry")] + [InlineData("https://example.test/base#entry")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorInCallingScope(string reference) { McpServerTool tool = CreateStructuredOutputTestTool( new DynamicReferenceOutput { Child = new() { Id = "42" } }, - """ + $$$""" { "$id":"https://example.test/root", "$dynamicAnchor":"entry", + "$schema":"https://json-schema.org/draft/2020-12/schema", "$ref":"#/$defs/base", "required":["note"], "$defs":{ @@ -1756,7 +1761,7 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorI "$id":"base", "$dynamicAnchor":"entry", "type":"object", - "properties":{"child":{"$dynamicRef":"#entry"}} + "properties":{"child":{"$dynamicRef":"{{{reference}}}"}} } } } @@ -1792,6 +1797,289 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclu Assert.False(result.TryGetProperty("count", out _)); } + [Theory] + [InlineData(5, false, false)] + [InlineData(24, false, true)] + [InlineData(129, false, true)] + [InlineData(5, true, true)] + [InlineData(24, true, false)] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesIndependentBranchRepairs(int count, bool dictionary, bool mixed) + { + IndependentUnionOutput[] items = Enumerable.Range(0, count) + .Select(index => new IndependentUnionOutput { Tag = mixed ? (index % 2).ToString() : "0" }).ToArray(); + object output = dictionary + ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/{pair.index}~", pair => pair.item) + : items; + string container = dictionary ? "\"type\":\"object\",\"additionalProperties\"" : "\"type\":\"array\",\"items\""; + McpServerTool tool = CreateStructuredOutputTestTool(output, $$$""" + { + {{{container}}}:{"$ref":"#/$defs/entry"}, + "$defs":{"entry":{"oneOf":[ + {"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]}} + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + IEnumerable repaired = dictionary + ? result.EnumerateObject().Select(property => property.Value) + : result.EnumerateArray(); + foreach (JsonElement item in repaired) + { + string tag = item.GetProperty("tag").GetString()!; + Assert.Equal(JsonValueKind.Null, item.GetProperty("note" + tag).ValueKind); + Assert.False(item.TryGetProperty(tag == "0" ? "note1" : "note0", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesNestedBranchRepairs() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new { Tag = "outer", Note = (string?)null, Items = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray() }, + """ + { + "oneOf":[ + {"required":["tag","note","items"],"properties":{ + "tag":{"const":"outer"},"note":{"type":"null"}, + "items":{"type":"array","items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]}} + },"additionalProperties":false}, + {"properties":{"tag":{"const":"other"}},"required":["tag","other"]} + ] + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithLocalReferences() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(), + """ + { + "items":{"oneOf":[ + {"$ref":"#/$defs/zero","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"additionalProperties":false}, + {"$ref":"#/$defs/one","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"additionalProperties":false} + ]}, + "$defs":{"zero":{"required":["tag","note0"]},"one":{"required":["tag","note1"]}} + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("https://example.test/root")] + [InlineData("urn:example:root")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesAtEscapedSchemaPaths(string id) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new Dictionary + { + ["entry/%2F#~"] = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(), + }, + $$$""" + { + "$id":"{{{id}}}", + "properties":{"entry/%2F#~":{"items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]} } } + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(5)] + [InlineData(129)] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithOmittedDescendants(int count) + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, count).Select(_ => new NestedIndependentUnionOutput()).ToArray(), + """ + { + "items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} + ]} + } + """, + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("expando")] + [InlineData("generic-string")] + [InlineData("generic-integer")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericOnlyDictionaryValue(string kind) + { + object output; + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (kind == "expando") + { + ExpandoObject entries = new(); + ((IDictionary)entries)["first"] = new StructDictionaryChild { Count = 5 }; + ((IDictionary)entries)["entry/~"] = new StructDictionaryChild(); + output = entries; + } + else if (kind == "generic-string") + { + output = new GenericOnlyDictionary( + new Dictionary { ["first"] = new() { Count = 5 }, ["entry/~"] = default }); + options.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseUpper; + } + else + { + output = new GenericOnlyDictionary( + new Dictionary { [1] = new() { Count = 5 }, [42] = default }); + options.Converters.Add(new FormattedIntegerKeyConverter()); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal([0, 5], result.EnumerateObject().Select(property => property.Value.GetProperty("count").GetInt32()).OrderBy(value => value)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInvokeExcludedGetter() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + McpServerTool tool = CreateStructuredOutputTestTool( + new ExcludedThrowingGetterOutput(), + """{"type":"object","anyOf":[{"required":["count"]},{"required":["note"]}]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("count", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExplicitlyIncludedReadOnlyMember(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + if (sourceGenerated) + { + options.TypeInfoResolver = JsonContext2.Default; + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitlyIncludedReadOnlyOutput(), """{"required":["count"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(0, result.GetProperty("count").GetInt32()); + } + + [Theory] + [InlineData("entry")] + [InlineData("https://example.test/entry")] + [InlineData("entry#value")] + [InlineData("entry#/$defs/a~1b~0c")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResource(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + $$$""" + { + "$id":"https://example.test/root", + "$defs":{"entry":{ + "$id":"entry","$anchor":"value","required":["id"], + "$defs":{"a/b~c":{"required":["id"]}} + }}, + "$ref":"{{{reference}}}" + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + } + + [Theory] + [InlineData("nested/entry")] + [InlineData("#/$defs/entry/$defs/shape")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesReferencesInEmbeddedBaseUri(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + $$$""" + { + "$id":"https://example.test/base/root", + "$defs":{ + "entry":{"$id":"nested/entry","$defs":{"shape":{"$ref":"../shared"}},"$ref":"#/$defs/shape"}, + "shared":{"$id":"shared","required":["id"]} + }, + "$ref":"{{{reference}}}" + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + } + + [Theory] + [InlineData("""{"required":"note"}""")] + [InlineData("""{"$ref":"#/$defs/missing"}""")] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenSchemaCannotBeEvaluated(string schema) + { + McpServerTool tool = CreateStructuredOutputTestTool(new DefaultRequestIdOutput(), schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("id", out _)); + } + + private sealed class IndependentUnionOutput + { + public string Tag { get; init; } = "0"; + public string? Note0 { get; init; } + public string? Note1 { get; init; } + } + + private sealed class NestedIndependentUnionOutput + { + public string Tag { get; init; } = "0"; + public NestedDefaultedStructuredChild Child0 { get; init; } + public NestedDefaultedStructuredChild Child1 { get; init; } + } + + private sealed class ExcludedThrowingGetterOutput + { + public int Count => throw new InvalidOperationException("Excluded getter was invoked."); + public string? Note { get; init; } + } + + private sealed class ExplicitlyIncludedReadOnlyOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count => 0; + } + + private sealed class GenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary + { + public TValue this[TKey key] => entries[key]; + public IEnumerable Keys => entries.Keys; + public IEnumerable Values => entries.Values; + public int Count => entries.Count; + public bool ContainsKey(TKey key) => entries.ContainsKey(key); + public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!); + public IEnumerator> GetEnumerator() => entries.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) => new(McpJsonUtilities.DefaultOptions) { @@ -3020,6 +3308,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(DateTimeOffset?))] [JsonSerializable(typeof(Person))] [JsonSerializable(typeof(WeatherRequest))] + [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests ===== From 351451e8921067e515b8642d39fb2d16d4252505 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 28 Oct 2026 12:28:59 +0800 Subject: [PATCH 13/30] Fix structured output recovery review issues --- .../Server/AIFunctionMcpServerTool.cs | 140 +++++++++++++++++- .../Server/McpServerToolTests.cs | 64 ++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index a1b2fddb2..46ea8a348 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -831,6 +831,28 @@ void EnqueueRepairs(JsonNode instance, int depth) pending.Enqueue(new(instance, candidates, depth + 1)); HashSet grouped = new(groups.SelectMany(static group => group), StringComparer.Ordinal); + // Combine alternatives at distinct instance paths before trying isolated subsets. + var groupsByPath = groups.Where(static group => group.Count > 0) + .GroupBy(static group => group.First().Split('\0')[0], StringComparer.Ordinal).ToArray(); + List> combinations = [new(StringComparer.Ordinal)]; + foreach (var pathGroups in groupsByPath) + { + combinations = combinations.SelectMany(combination => pathGroups.Select(group => + new HashSet(combination.Concat(group), StringComparer.Ordinal))) + .Take(maxCandidateSetEvaluations / 2).ToList(); + } + if (groupsByPath.Length > 1) + { + foreach (HashSet combination in combinations) + { + List combinedCandidates = candidates.Where(candidate => + combination.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList(); + if (combinedCandidates.Count > 0 && combinedCandidates.Count < candidates.Count) + { + pending.Enqueue(new(instance, combinedCandidates, depth + 1)); + } + } + } foreach (HashSet group in groups) { List branchCandidates = candidates.Where(candidate => @@ -1532,6 +1554,33 @@ private static bool TryGetSourceObjectAtPath( continue; } + if (value is IDictionary stringDictionary && serializedParent is JsonObject genericSerializedDictionary) + { + using IEnumerator> serializedEntries = genericSerializedDictionary.GetEnumerator(); + bool found = false; + object? foundValue = null; + foreach (KeyValuePair entry in stringDictionary) + { + if (!serializedEntries.MoveNext()) + { + break; + } + + if (string.Equals(serializedEntries.Current.Key, segment, StringComparison.Ordinal)) + { + foundValue = entry.Value; + found = true; + break; + } + } + + if (found) + { + value = foundValue; + continue; + } + } + if (int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && value is IEnumerable enumerable) { int currentIndex = 0; @@ -1650,6 +1699,17 @@ private static bool TryGetSourceMember( { if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null) { + if (property.Set is null && + (property.AttributeProvider is not ICustomAttributeProvider attributes || + !attributes.IsDefined(typeof(JsonIgnoreAttribute), inherit: true)) && + (property.AttributeProvider is FieldInfo + ? serializerOptions.IgnoreReadOnlyFields + : serializerOptions.IgnoreReadOnlyProperties && + serializerOptions.GetTypeInfo(property.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary))) + { + return false; + } + value = property.Get(source); declaredType = property.PropertyType; propertyInfo = property; @@ -1732,7 +1792,22 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen resolvedSchema = null; if (!reference.StartsWith("#", StringComparison.Ordinal)) { - return false; + JsonNode documentRoot = schemaRoot.Root; + Uri initialBase = new("https://mcp.invalid/"); + if (!TryFindResource(documentRoot, schemaRoot, null, initialBase, out _, out Uri? baseUri) || + !Uri.TryCreate(baseUri, reference, out Uri? target)) + { + return false; + } + + string fragment = target.Fragment; + Uri resourceUri = new(target.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped)); + if (!TryFindResource(documentRoot, null, resourceUri, initialBase, out JsonNode? resource, out _) || resource is null) + { + return false; + } + + return TryResolveLocalReference(resource, fragment.Length == 0 ? "#" : fragment, out resolvedSchema); } string pointer = Uri.UnescapeDataString(reference[1..]); @@ -1769,6 +1844,69 @@ private static bool TryResolveLocalReference(JsonNode schemaRoot, string referen return true; } + private static bool TryFindResource(JsonNode? node, JsonNode? soughtNode, Uri? soughtUri, Uri baseUri, out JsonNode? resource, out Uri? resourceUri) + { + resource = null; + resourceUri = null; + if (node is JsonObject obj && obj["$id"] is JsonValue id && id.TryGetValue(out string? idText) && + Uri.TryCreate(baseUri, idText, out Uri? updatedBase)) + { + baseUri = updatedBase; + } + + if ((soughtNode is not null && ReferenceEquals(node, soughtNode)) || + (soughtUri is not null && node is JsonObject resourceObject && resourceObject.ContainsKey("$id") && + Uri.Compare(baseUri, soughtUri, UriComponents.AbsoluteUri, UriFormat.SafeUnescaped, StringComparison.Ordinal) == 0)) + { + resource = node; + resourceUri = baseUri; + return true; + } + + if (node is not JsonObject schema) + { + return false; + } + + foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" }) + { + if (schema[keyword] is JsonObject schemas) + { + foreach (KeyValuePair child in schemas) + { + if (TryFindResource(child.Value, soughtNode, soughtUri, baseUri, out resource, out resourceUri)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (schema[keyword] is JsonArray schemas) + { + foreach (JsonNode? child in schemas) + { + if (TryFindResource(child, soughtNode, soughtUri, baseUri, out resource, out resourceUri)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not", "propertyNames" }) + { + if (TryFindResource(schema[keyword], soughtNode, soughtUri, baseUri, out resource, out resourceUri)) + { + return true; + } + } + + return false; + } + private static bool TryFindSchemaAnchor(JsonNode? node, string anchor, out JsonNode? resolvedSchema, bool dynamicOnly = false, bool isResourceRoot = true) { resolvedSchema = null; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index ac62c4c5f..c56c61ee4 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1792,6 +1792,70 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclu Assert.False(result.TryGetProperty("count", out _)); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsIndependentArrayUnions() + { + const string schema = """{"type":"array","items":{"oneOf":[{"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},{"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}]}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(Enumerable.Range(0, 5).Select(_ => new ManyBranchStructuredOutput { Tag = "0" }).ToArray(), schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note0").ValueKind)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExpandoMember() + { + var output = new System.Dynamic.ExpandoObject(); + ((IDictionary)output)["child"] = new ExplicitSchemaOutputWithMemberIgnore { Id = "42" }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","properties":{"child":{"type":"object","required":["ignoredNote"],"properties":{"ignoredNote":{"type":"null"}}}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("ignoredNote").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadIgnoredGetter(bool noAttributeProvider) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + if (noAttributeProvider) + { + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ThrowingGetterOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + } + }); + options.TypeInfoResolver = resolver; + } + McpServerTool tool = CreateStructuredOutputTestTool(new ThrowingGetterOutput(), """{"anyOf":[{"required":["count"]},{"required":["note"],"properties":{"note":{"type":"null"}}}]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResource() + { + McpServerTool tool = CreateStructuredOutputTestTool(new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/root","default":{"$id":"entry"},"$defs":{"entry":{"$id":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}},"$ref":"entry"}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + private sealed class ThrowingGetterOutput + { + public int Count => throw new InvalidOperationException("Excluded getter was invoked"); + public string? Note { get; init; } + } + private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) => new(McpJsonUtilities.DefaultOptions) { From 9b2f5c8451bfdf5563b20d8bebf6412488f8ef9f Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 28 Oct 2026 10:47:12 -0700 Subject: [PATCH 14/30] Fix structured output recovery contracts and bound schema traversal --- .../Server/AIFunctionMcpServerTool.cs | 797 +++++++++++------- .../Program.cs | 62 +- .../Server/McpServerToolRepairSearchTests.cs | 198 +++++ .../Server/McpServerToolTests.cs | 498 +++++++++++ 4 files changed, 1263 insertions(+), 292 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 5ae4485a2..4c19fd9f7 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -20,7 +20,6 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; private OutputSchemaCache? _outputSchema; - private readonly Lazy _serializerTypeInfoIncludingDefaults; /// /// Creates an instance for a method, specified via a instance. @@ -230,15 +229,6 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider AIFunction = function; ProtocolTool = tool; - _serializerTypeInfoIncludingDefaults = new Lazy(() => - { - JsonSerializerOptions options = new(AIFunction.JsonSerializerOptions) - { - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - }; - return options.GetTypeInfo(typeof(object)); - }, LazyThreadSafetyMode.ExecutionAndPublication); - _metadata = metadata; } @@ -751,31 +741,12 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - JsonNode? resultNodeIncludingDefaults = resultNode; - if (originalResult is not null and not JsonElement and not JsonNode && - AIFunction.JsonSerializerOptions.DefaultIgnoreCondition is not JsonIgnoreCondition.Never) - { - try - { - JsonElement completeResult = JsonSerializer.SerializeToElement( - originalResult, _serializerTypeInfoIncludingDefaults.Value); - resultNodeIncludingDefaults = JsonSerializer.SerializeToNode( - completeResult, McpJsonUtilities.JsonContext.Default.JsonElement); - } - catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) - { - // A value omitted from the actual result may fail when the ignore policy is - // relaxed. Required members can still be recovered individually from metadata. - resultNodeIncludingDefaults = null; - } - } - JsonNode? repairedResult = RepairStructuredResult( resultNode, - resultNodeIncludingDefaults, schemaNode, schema, - originalResult, + originalResult is null ? null : new SourceRecoveryContext( + originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), AIFunction.JsonSerializerOptions); return repairedResult is null @@ -786,19 +757,20 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) /// /// Restores omitted required properties only when the complete result validates against the /// published schema. The serializer can omit a value because of either the global ignore - /// policy or a member-level . Candidate values are therefore - /// read from the source object when the second serialization snapshot does not contain them. + /// policy or a member-level . Candidate values are read only + /// from members of the active serialization contract, without serializing unrelated members. /// private static JsonNode? RepairStructuredResult( JsonNode resultNode, - JsonNode? resultNodeIncludingDefaults, JsonNode schemaNode, JsonSchema schema, - object? originalResult, + SourceRecoveryContext? originalResult, JsonSerializerOptions serializerOptions, int remainingDepth = 32, JsonNode? schemaDocument = null, - SchemaReferenceResolver? references = null) + SchemaReferenceResolver? references = null, + List? dynamicScope = null, + RepairTraversalBudget? traversalBudget = null) { // An explicit schema may deliberately omit optional nulls. Preserve that valid output // before looking for missing required members. @@ -813,31 +785,35 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; schemaDocument ??= schemaNode; references ??= new(schemaDocument); - Dictionary branchSchemas = new(); + traversalBudget ??= new(); + Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = new(); - void EnqueueRepairs(JsonNode instance, int depth) + JsonNode? EnqueueRepairs(JsonNode instance, int depth) { if (depth >= remainingDepth) { - return; + return null; } Dictionary contexts = new(StringComparer.Ordinal); List groups = []; CollectSchemaContexts( instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, - new HashSet(StringComparer.Ordinal), groups, references); + new SchemaTraversal(traversalBudget), groups, references, dynamicScope); List candidates = CreateRepairCandidates( - instance, resultNodeIncludingDefaults, originalResult, serializerOptions, contexts); + instance, originalResult, serializerOptions, contexts); if (candidates.Count == 0) { - return; + return null; } HashSet grouped = new(groups.SelectMany(static group => group.Keys), StringComparer.Ordinal); + Dictionary candidatesByKey = candidates.ToDictionary(static candidate => candidate.Key, StringComparer.Ordinal); + List ungrouped = candidates.Where(candidate => !grouped.Contains(candidate.Key)).ToList(); + Dictionary<(JsonNode Applicator, string Path), JsonNode> exclusiveChoices = new(); JsonNode combinedResult = instance.DeepClone(); bool hasCombinedRepairs = false; - foreach (RepairCandidate candidate in candidates.Where(candidate => !grouped.Contains(candidate.Key))) + foreach (RepairCandidate candidate in ungrouped) { ApplyRepairCandidate(combinedResult, candidate); hasCombinedRepairs = true; @@ -845,14 +821,23 @@ void EnqueueRepairs(JsonNode instance, int depth) foreach (RepairCandidateGroup group in groups) { + if (group.Choices.Any(choice => + exclusiveChoices.TryGetValue((choice.Applicator, choice.InstancePath), out JsonNode? selected) && + !ReferenceEquals(selected, choice.Branch))) + { + continue; + } + if (!TryGetNodeAtPath(combinedResult, group.InstancePath, out JsonNode? currentBranch) || currentBranch is null) { continue; } - if (!branchSchemas.TryGetValue(group.Schema, out JsonSchema? branchSchema)) + var schemaKey = (group.Schema, references.GetScopeKey(group.DynamicScope)); + if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) { JsonNode document = schemaDocument.DeepClone(); + references.BindDynamicReferences(schemaDocument, document, group.DynamicScope); if (document is JsonObject documentObject) { documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri; @@ -864,7 +849,7 @@ void EnqueueRepairs(JsonNode instance, int depth) ["$defs"] = new JsonObject { ["document"] = document }, }; TryBuildJsonSchema(branchDocument, out branchSchema); - branchSchemas.Add(group.Schema, branchSchema); + branchSchemas.Add(schemaKey, branchSchema); } if (branchSchema is null) @@ -873,11 +858,20 @@ void EnqueueRepairs(JsonNode instance, int depth) } // Compose independently valid subtree repairs before searching whole-document subsets. + List groupCandidates = []; + foreach (string key in group.Keys) + { + if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) + { + groupCandidates.Add(candidate); + } + } + foreach (bool includeDescendants in new[] { false, true }) { - List branchCandidates = candidates.Where(candidate => - group.Keys.Contains(candidate.Key) && - (includeDescendants || candidate.ParentPath == group.InstancePath)).ToList(); + List branchCandidates = includeDescendants + ? groupCandidates + : groupCandidates.Where(candidate => candidate.ParentPath == group.InstancePath).ToList(); JsonNode branchResult = currentBranch.DeepClone(); foreach (RepairCandidate candidate in branchCandidates) { @@ -887,16 +881,16 @@ void EnqueueRepairs(JsonNode instance, int depth) bool branchIsValid = IsSchemaValid(branchSchema, branchResult); if (!branchIsValid && includeDescendants && branchCandidates.Count > 0 && depth + 1 < remainingDepth) { - TryGetNodeAtPath(resultNodeIncludingDefaults, group.InstancePath, out JsonNode? completeBranch); - object? branchSource = null; + SourceRecoveryContext? branchSource = null; if (originalResult is not null) { - TryGetSourceObjectAtPath(originalResult, instance, group.InstancePath, serializerOptions, out branchSource); + TryGetSourceObjectAtPath(originalResult, instance, group.InstancePath, out branchSource); } if (RepairStructuredResult( - branchResult, completeBranch, group.Schema, branchSchema, branchSource, - serializerOptions, remainingDepth - depth - 1, schemaDocument, references) is JsonNode repairedBranch) + branchResult, group.Schema, branchSchema, branchSource, + serializerOptions, remainingDepth - depth - 1, schemaDocument, references, + group.DynamicScope, traversalBudget) is JsonNode repairedBranch) { branchResult = repairedBranch; branchIsValid = true; @@ -919,6 +913,11 @@ void EnqueueRepairs(JsonNode instance, int depth) } hasCombinedRepairs = true; + foreach (RepairExclusiveChoice choice in group.Choices) + { + exclusiveChoices[(choice.Applicator, choice.InstancePath)] = choice.Branch; + } + break; } } @@ -926,14 +925,33 @@ void EnqueueRepairs(JsonNode instance, int depth) if (hasCombinedRepairs) { + // The common case composes independent repairs. Avoid allocating subset queues + // (or repeatedly scanning the full candidate list) when that result already works. + if (IsSchemaValid(schema, combinedResult)) + { + return combinedResult; + } + pending.Enqueue(new(combinedResult, [], depth + 1)); } pending.Enqueue(new(instance, candidates, depth + 1)); foreach (RepairCandidateGroup group in groups) { - List branchCandidates = candidates.Where(candidate => - group.Keys.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList(); + if (pending.Count >= maxCandidateSetEvaluations) + { + break; + } + + List branchCandidates = [.. ungrouped]; + foreach (string key in group.Keys) + { + if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) + { + branchCandidates.Add(candidate); + } + } + if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count && pending.Count < maxCandidateSetEvaluations) { @@ -953,9 +971,14 @@ void EnqueueRepairs(JsonNode instance, int depth) fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1)); } } + + return null; } - EnqueueRepairs(resultNode, 0); + if (EnqueueRepairs(resultNode, 0) is JsonNode composedResult) + { + return composedResult; + } for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) { RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue(); @@ -976,7 +999,10 @@ void EnqueueRepairs(JsonNode instance, int depth) return candidateResult; } - EnqueueRepairs(candidateResult, attempt.Depth); + if (EnqueueRepairs(candidateResult, attempt.Depth) is JsonNode repairedResult) + { + return repairedResult; + } } return null; @@ -1034,8 +1060,7 @@ private static bool IsSchemaValid(JsonSchema schema, JsonNode instance) private static List CreateRepairCandidates( JsonNode resultNode, - JsonNode? resultNodeIncludingDefaults, - object? originalResult, + SourceRecoveryContext? originalResult, JsonSerializerOptions serializerOptions, Dictionary contexts) { @@ -1062,16 +1087,7 @@ private static List CreateRepairCandidates( continue; } - bool hasCompleteValue = TryGetNodeAtPath( - resultNodeIncludingDefaults, - AppendJsonPointer(contextEntry.Key, propertyName), - out JsonNode? completeValue); - - if (hasCompleteValue) - { - candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, completeValue?.DeepClone())); - } - else if (TryGetSourceValue( + if (TryGetSourceValue( originalResult, resultNode, contextEntry.Key, @@ -1107,7 +1123,40 @@ private sealed record RepairCandidate(string ParentPath, string PropertyName, Js private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); - private sealed record RepairCandidateGroup(HashSet Keys, string InstancePath, JsonNode Schema); + private sealed record RepairExclusiveChoice(JsonNode Applicator, JsonNode Branch, string InstancePath); + + private sealed record RepairCandidateGroup( + HashSet Keys, string InstancePath, JsonNode Schema, + List DynamicScope, List Choices); + + private sealed class RepairTraversalBudget + { + // Shared by document attempts and recursive branch repairs, including cached merges. + private int _remainingWork = 1_000_000; + + internal bool TrySpend(int work) + { + if (work > _remainingWork) + { + _remainingWork = 0; + return false; + } + + _remainingWork -= work; + return true; + } + } + + private sealed class SchemaTraversal(RepairTraversalBudget budget) + { + internal RepairTraversalBudget Budget { get; } = budget; + internal Dictionary<(JsonNode Schema, string Path, string Scope), SchemaTraversalResult> Completed { get; } = new(); + internal HashSet<(JsonNode Schema, string Path, string Scope)> Active { get; } = []; + internal int IncompleteVersion { get; set; } + } + + private sealed record SchemaTraversalResult( + Dictionary Contexts, List Groups); private static string GetSchemaPointer(JsonNode schema, JsonNode resource) { @@ -1129,7 +1178,7 @@ private static void CollectSchemaContexts( JsonNode schemaRoot, string instancePath, Dictionary contexts, - HashSet activeReferences, + SchemaTraversal traversal, List groups, SchemaReferenceResolver references, List? dynamicScope = null) @@ -1148,15 +1197,43 @@ private static void CollectSchemaContexts( if (!ReferenceEquals(schemaRoot, schemaObject) && schemaObject.ContainsKey("$id")) { schemaRoot = schemaObject; - dynamicScope = [.. dynamicScope, schemaRoot]; + dynamicScope = SchemaReferenceResolver.EnterResource(dynamicScope, schemaRoot); + } + + var state = (schemaNode, instancePath, references.GetScopeKey(dynamicScope)); + if (!traversal.Budget.TrySpend(1)) + { + return; } + if (traversal.Completed.TryGetValue(state, out SchemaTraversalResult? completed)) + { + if (traversal.Budget.TrySpend(completed.Contexts.Count + completed.Groups.Count)) + { + MergeSchemaContexts(contexts, completed.Contexts); + groups.AddRange(completed.Groups); + } + + return; + } + + if (traversal.Active.Count >= 256 || !traversal.Active.Add(state)) + { + traversal.IncompleteVersion++; + return; + } + + int incompleteVersion = traversal.IncompleteVersion; + Dictionary destinationContexts = contexts; + List destinationGroups = groups; + contexts = new(StringComparer.Ordinal); + groups = []; + foreach (string referenceKeyword in new[] { "$ref", "$dynamicRef" }) { if (schemaObject[referenceKeyword] is JsonValue refValue && refValue.TryGetValue(out string? refText) && - refText is not null && - activeReferences.Add(schemaRoot.GetPath() + "|" + refText + "|" + instancePath)) + refText is not null) { if (references.TryResolve(schemaRoot, refText, out JsonNode? referencedSchema, out JsonNode referencedResource)) { @@ -1176,11 +1253,9 @@ refText is not null && } } - CollectSchemaContexts(instanceNode, referencedSchema, referencedResource, instancePath, contexts, activeReferences, groups, references, - ReferenceEquals(schemaRoot, referencedResource) ? dynamicScope : [.. dynamicScope, referencedResource]); + CollectSchemaContexts(instanceNode, referencedSchema, referencedResource, instancePath, contexts, traversal, groups, references, + SchemaReferenceResolver.EnterResource(dynamicScope, referencedResource)); } - - activeReferences.Remove(schemaRoot.GetPath() + "|" + refText + "|" + instancePath); } } @@ -1218,7 +1293,7 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal), + traversal, groups, references, dynamicScope); } } @@ -1246,7 +1321,7 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal), + traversal, groups, references, dynamicScope); } } @@ -1260,7 +1335,7 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, - new HashSet(activeReferences, StringComparer.Ordinal), + traversal, groups, references, dynamicScope); } @@ -1268,7 +1343,7 @@ refText is not null && { CollectSchemaBranch( property.Value, unevaluatedPropertySchema, schemaRoot, - AppendJsonPointer(instancePath, property.Key), contexts, activeReferences, groups, references, dynamicScope); + AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope); } } } @@ -1288,7 +1363,7 @@ refText is not null && schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), contexts, - new HashSet(activeReferences, StringComparer.Ordinal), + traversal, groups, references, dynamicScope); } @@ -1299,7 +1374,7 @@ refText is not null && CollectSchemaBranch( arrayInstance[i], subschema, schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), - contexts, activeReferences, groups, references, dynamicScope); + contexts, traversal, groups, references, dynamicScope); } } } @@ -1336,7 +1411,7 @@ refText is not null && schemaRoot, instancePath, contexts, - new HashSet(activeReferences, StringComparer.Ordinal), + traversal, groups, references, dynamicScope); } } @@ -1357,11 +1432,12 @@ refText is not null && { CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, contexts, - new HashSet(activeReferences, StringComparer.Ordinal), groups, references, dynamicScope); + traversal, groups, references, dynamicScope); } else { - CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); + CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + compositionKeyword == "oneOf" ? composition : null); } } } @@ -1371,14 +1447,38 @@ refText is not null && { if (schemaObject["then"] is JsonNode thenSchema) { - CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); + CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope); } if (schemaObject["else"] is JsonNode elseSchema) { - CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, activeReferences, groups, references, dynamicScope); + CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope); } } + + groups = groups.Distinct().ToList(); + traversal.Active.Remove(state); + // A cycle or depth cutoff produces a partial summary that cannot be reused + // when this schema is reached later through a different caller. + if (incompleteVersion == traversal.IncompleteVersion) + { + traversal.Completed[state] = new(contexts, groups); + } + if (traversal.Budget.TrySpend(contexts.Count + groups.Count)) + { + MergeSchemaContexts(destinationContexts, contexts); + destinationGroups.AddRange(groups); + } + } + + private static void MergeSchemaContexts( + Dictionary destination, + Dictionary source) + { + foreach (KeyValuePair entry in source) + { + GetOrCreateSchemaContext(destination, entry.Key).Required.UnionWith(entry.Value.Required); + } } private static void CollectSchemaBranch( @@ -1387,15 +1487,24 @@ private static void CollectSchemaBranch( JsonNode schemaRoot, string instancePath, Dictionary contexts, - HashSet activeReferences, + SchemaTraversal traversal, List groups, SchemaReferenceResolver references, - List dynamicScope) + List dynamicScope, + JsonNode? exclusiveApplicator = null) { Dictionary branchContexts = new(StringComparer.Ordinal); + List branchGroups = []; CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, branchContexts, - new HashSet(activeReferences, StringComparer.Ordinal), groups, references, dynamicScope); + traversal, branchGroups, references, dynamicScope); + List choices = exclusiveApplicator is null + ? [] : [new(exclusiveApplicator, branch, instancePath)]; + foreach (RepairCandidateGroup branchGroup in branchGroups) + { + groups.Add(branchGroup with { Choices = [.. choices, .. branchGroup.Choices] }); + } + HashSet group = new(StringComparer.Ordinal); foreach (KeyValuePair entry in branchContexts) { @@ -1408,7 +1517,8 @@ private static void CollectSchemaBranch( if (group.Count > 0) { - groups.Add(new(group, instancePath, branch)); + groups.Add(new(group, instancePath, branch, + SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)), choices)); } } @@ -1465,7 +1575,7 @@ private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? } private static bool TryGetSourceValue( - object? sourceRoot, + SourceRecoveryContext? sourceRoot, JsonNode serializedRoot, string parentPath, string propertyName, @@ -1473,29 +1583,21 @@ private static bool TryGetSourceValue( out JsonNode? value) { value = null; - if (sourceRoot is null || !TryGetSourceObjectAtPath(sourceRoot, serializedRoot, parentPath, serializerOptions, out object? sourceParent) || sourceParent is null) - { - return false; - } - - if (!TryGetSourceMember( - sourceParent, - propertyName, - serializerOptions, - out object? sourceValue, - out Type? declaredType, - out JsonPropertyInfo? propertyInfo)) + if (sourceRoot is null || + !TryGetSourceObjectAtPath(sourceRoot, serializedRoot, parentPath, out SourceRecoveryContext? parent) || + parent is null || + !TryGetNodeAtPath(serializedRoot, parentPath, out JsonNode? serializedParent) || + parent.GetChild(propertyName, serializedParent) is not { Property: { } property, Owner: { } owner } member) { return false; } - if (!WasOmittedBySupportedIgnoreCondition( - sourceParent, sourceValue, propertyInfo, serializerOptions)) + if (!WasOmittedBySupportedIgnoreCondition(owner, member.Value, property, serializerOptions)) { return false; } - return TrySerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo, out value); + return TrySerializeSourceValue(member.Value, member.Contract.Type, serializerOptions, property, member.NumberHandling, out value); } private static bool WasOmittedBySupportedIgnoreCondition( @@ -1511,20 +1613,28 @@ private static bool WasOmittedBySupportedIgnoreCondition( return false; } + if (value is null && serializerOptions.RespectNullableAnnotations && propertyInfo is { IsGetNullable: false }) + { + return false; + } + if (propertyInfo?.ShouldSerialize is { } shouldSerialize && - !IsJsonIgnoreConditionPredicate(shouldSerialize) && + (ignoreAttribute is null || !IsJsonIgnoreConditionPredicate(shouldSerialize)) && !shouldSerialize(sourceParent, value)) { return false; } JsonIgnoreCondition ignoreCondition = ignoreAttribute?.Condition ?? serializerOptions.DefaultIgnoreCondition; + // For an absent value-type member, the serializer already compared against default. + // Boxed Equals can disagree with the EqualityComparer used by the serializer. return ignoreCondition switch { JsonIgnoreCondition.WhenWritingNull => value is null, JsonIgnoreCondition.WhenWritingDefault => propertyInfo is not null && - IsDefaultValue(value, propertyInfo.PropertyType), + (value is null || (propertyInfo.PropertyType.IsValueType && + Nullable.GetUnderlyingType(propertyInfo.PropertyType) is null)), _ => false, }; } @@ -1537,7 +1647,7 @@ propertyInfo is not null && : null; private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) => - GetIgnoreAttribute(propertyInfo) is null && propertyInfo.Set is null && + propertyInfo.ShouldSerialize is null && GetIgnoreAttribute(propertyInfo) is null && propertyInfo.Set is null && (propertyInfo.AttributeProvider is FieldInfo ? serializerOptions.IgnoreReadOnlyFields : serializerOptions.IgnoreReadOnlyProperties && @@ -1554,41 +1664,11 @@ private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) string.Equals(propertyInfoType.Name, "JsonPropertyInfo`1", StringComparison.Ordinal); } - private static bool IsDefaultValue(object? value, Type valueType) - { - if (value is null) - { - return true; - } - - return Nullable.GetUnderlyingType(valueType) is null && - valueType.IsValueType && - value.Equals(CreateDefaultValue(valueType)); - } - - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "Trimming", - "IL2067:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", - Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "Trimming", - "IL2072:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", - Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] - private static object CreateDefaultValue(Type valueType) - { -#if NETSTANDARD2_0 - return System.Runtime.Serialization.FormatterServices.GetUninitializedObject(valueType); -#else - return System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(valueType); -#endif - } - private static bool TryGetSourceObjectAtPath( - object sourceRoot, + SourceRecoveryContext sourceRoot, JsonNode serializedRoot, string path, - JsonSerializerOptions serializerOptions, - out object? value) + out SourceRecoveryContext? value) { value = sourceRoot; if (path.Length == 0) @@ -1602,132 +1682,273 @@ private static bool TryGetSourceObjectAtPath( return false; } - string currentPath = string.Empty; + JsonNode? serializedParent = serializedRoot; foreach (string rawSegment in path.Substring(1).Split('/')) { string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); - TryGetNodeAtPath(serializedRoot, currentPath, out JsonNode? serializedParent); - currentPath = AppendJsonPointer(currentPath, segment); - if (value is null) + if (value?.GetChild(segment, serializedParent) is not { } child) { + value = null; return false; } - if (value is JsonNode jsonNode) + value = child; + serializedParent = serializedParent switch { - if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(segment, out JsonNode? childNode)) - { - value = childNode; - continue; - } + JsonObject jsonObject => jsonObject[segment], + JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < jsonArray.Count => jsonArray[index], + _ => null, + }; + } - if (jsonNode is JsonArray jsonArray && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int jsonIndex) && - jsonIndex >= 0 && jsonIndex < jsonArray.Count) + return true; + } + + private sealed class SourceRecoveryContext( + object? value, JsonTypeInfo contract, JsonNumberHandling? numberHandling = null, + JsonPropertyInfo? property = null, object? owner = null) + { + private readonly Dictionary _children = new(StringComparer.Ordinal); + private List? _items; + private bool _dictionaryRead; + + internal object? Value { get; } = value; + internal JsonTypeInfo Contract { get; } = contract; + internal JsonNumberHandling? NumberHandling { get; } = numberHandling; + internal JsonPropertyInfo? Property { get; } = property; + internal object? Owner { get; } = owner; + + internal SourceRecoveryContext? GetChild(string name, JsonNode? serializedParent) + { + if (_children.TryGetValue(name, out SourceRecoveryContext? child)) + { + return child; + } + + try + { + child = GetChildCore(name, serializedParent); + } + catch (NotSupportedException) + { + // Missing metadata and opaque converter contracts cannot authorize recovery. + child = null; + } + + _children[name] = child; + return child; + } + + private SourceRecoveryContext? GetChildCore(string name, JsonNode? serializedParent) + { + if (Value is null || Property?.CustomConverter is not null) + { + return null; + } + + JsonSerializerOptions options = Contract.Options; + JsonTypeInfo typeInfo = GetActiveContract(); + if (typeInfo.Kind == JsonTypeInfoKind.Dictionary && + serializedParent is JsonObject serializedDictionary && Value is IEnumerable entries) + { + if (!_dictionaryRead) { - value = jsonArray[jsonIndex]; - continue; + _dictionaryRead = true; + using IEnumerator> properties = serializedDictionary.GetEnumerator(); + foreach (object entry in entries) + { + if (!properties.MoveNext()) + { + break; + } + + if (TryGetDictionaryEntryValue(entry, out object? entryValue)) + { + _children[properties.Current.Key] = new( + entryValue, options.GetTypeInfo(typeInfo.ElementType!), NumberHandling ?? typeInfo.NumberHandling); + } + } } - value = null; - return false; + return _children.TryGetValue(name, out SourceRecoveryContext? entryContext) ? entryContext : null; } - if (value is JsonElement jsonElement) + if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && serializedParent is JsonArray serializedArray && + int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < serializedArray.Count && Value is IEnumerable enumerable) { - if (jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(segment, out JsonElement childElement)) + object? item; + if (Value is IList list) { - value = childElement; - continue; - } + if (index >= list.Count) + { + return null; + } - if (jsonElement.ValueKind == JsonValueKind.Array && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int elementIndex) && - elementIndex >= 0 && elementIndex < jsonElement.GetArrayLength()) + item = list[index]; + } + else { - value = jsonElement[elementIndex]; - continue; + if (_items is null) + { + _items = []; + foreach (object? element in enumerable) + { + _items.Add(element); + if (_items.Count >= serializedArray.Count) + { + break; + } + } + } + + if (index >= _items.Count) + { + return null; + } + + item = _items[index]; } - value = null; - return false; + return new(item, options.GetTypeInfo(typeInfo.ElementType!), NumberHandling ?? typeInfo.NumberHandling); } - if (serializedParent is JsonObject serializedDictionary && - TryGetDictionaryValue(value, serializedDictionary, segment, serializerOptions, out object? dictionaryValue)) + if (typeInfo.Kind != JsonTypeInfoKind.Object) { - value = dictionaryValue; - continue; + return null; } - if (int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && value is IEnumerable enumerable) + JsonPropertyInfo? extensionData = null; + foreach (JsonPropertyInfo member in typeInfo.Properties) { - int currentIndex = 0; - bool found = false; - foreach (object? item in enumerable) + if (member.IsExtensionData) { - if (currentIndex++ == index) + extensionData = member; + } + else if (string.Equals(member.Name, name, StringComparison.Ordinal)) + { + if (member.Get is null || IsExcludedReadOnlyMember(member, options)) { - value = item; - found = true; - break; + return null; } - } - if (found) - { - continue; + object? memberValue = member.Get(Value); + JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); + return new(memberValue, memberContract, + GetEffectiveNumberHandling(member, typeInfo, memberContract), + member, Value); } } - if (!TryGetSourceMember(value, segment, serializerOptions, out object? memberValue, out _, out _)) + if (extensionData?.Get is not { } getExtensionData || + extensionData.CustomConverter is not null || IsExcludedReadOnlyMember(extensionData, options)) { - value = null; - return false; + return null; } - value = memberValue; - } + object? extensionValue = getExtensionData(Value); + if (extensionValue is null || extensionData.ShouldSerialize?.Invoke(Value, extensionValue) == false) + { + return null; + } - return true; - } + JsonTypeInfo extensionType = options.GetTypeInfo(extensionData.PropertyType); + if (extensionType.Kind != JsonTypeInfoKind.Dictionary) + { + return null; + } - private static bool TryGetDictionaryValue( - object dictionary, - JsonObject serializedDictionary, - string propertyName, - JsonSerializerOptions serializerOptions, - out object? value) - { - value = null; - JsonTypeInfo dictionaryTypeInfo; - try - { - dictionaryTypeInfo = serializerOptions.GetTypeInfo(dictionary.GetType()); - } - catch (NotSupportedException) - { - return false; + object? extensionEntry; + if (extensionValue is IDictionary objectData && objectData.TryGetValue(name, out extensionEntry)) + { + return new(extensionEntry, options.GetTypeInfo(extensionType.ElementType!)); + } + + if (extensionValue is IDictionary elementData && elementData.TryGetValue(name, out JsonElement elementEntry)) + { + return new(elementEntry, options.GetTypeInfo(typeof(JsonElement))); + } + + return null; } - if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary || dictionary is not IEnumerable entries) + private static JsonNumberHandling? GetEffectiveNumberHandling( + JsonPropertyInfo property, JsonTypeInfo declaringType, JsonTypeInfo memberType) { - return false; + if (property.CustomConverter is not null || memberType.Converter.GetType().Assembly != typeof(JsonSerializer).Assembly) + { + return null; + } + + Type type = memberType.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary + ? memberType.ElementType! + : memberType.Type; + type = Nullable.GetUnderlyingType(type) ?? type; + bool supportsNumberHandling = !type.IsEnum && (type == typeof(object) || + Type.GetTypeCode(type) is TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or TypeCode.UInt16 or + TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64 or + TypeCode.Single or TypeCode.Double or TypeCode.Decimal || + (type.Assembly == typeof(int).Assembly && type.FullName is "System.Half" or "System.Int128" or "System.UInt128")); + + return supportsNumberHandling + ? property.NumberHandling ?? declaringType.NumberHandling ?? memberType.NumberHandling + : null; } - using IEnumerator> properties = serializedDictionary.GetEnumerator(); - foreach (object entry in entries) + private JsonTypeInfo GetActiveContract() { - if (!properties.MoveNext()) + JsonSerializerOptions options = Contract.Options; + Type runtimeType = Value!.GetType(); + if (runtimeType == Contract.Type) { - return false; + return Contract; } - if (string.Equals(properties.Current.Key, propertyName, StringComparison.Ordinal)) + if (Contract.Type == typeof(object) && Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) { - return TryGetDictionaryEntryValue(entry, out value); + return options.GetTypeInfo(runtimeType); } - } - return false; + if (Nullable.GetUnderlyingType(Contract.Type) is { } underlyingType && + Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) + { + return options.GetTypeInfo(underlyingType); + } + + if (Contract.PolymorphismOptions is not { } polymorphism) + { + return Contract; + } + + foreach (JsonDerivedType derived in polymorphism.DerivedTypes) + { + if (derived.DerivedType == runtimeType) + { + return options.GetTypeInfo(runtimeType); + } + } + + if (polymorphism.UnknownDerivedTypeHandling == JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor) + { + Type? nearest = null; + foreach (JsonDerivedType derived in polymorphism.DerivedTypes) + { + if (derived.DerivedType.IsAssignableFrom(runtimeType) && + (nearest is null || nearest.IsAssignableFrom(derived.DerivedType))) + { + nearest = derived.DerivedType; + } + } + + if (nearest is not null) + { + return options.GetTypeInfo(nearest); + } + } + + return Contract; + } } #if !NETSTANDARD2_0 @@ -1756,72 +1977,12 @@ private static bool TryGetDictionaryEntryValue(object entry, out object? value) return false; } - private static bool TryGetSourceMember( - object source, - string propertyName, - JsonSerializerOptions serializerOptions, - out object? value, - out Type? declaredType, - out JsonPropertyInfo? propertyInfo) - { - value = null; - declaredType = null; - propertyInfo = null; - - if (source is JsonNode jsonNode) - { - if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(propertyName, out JsonNode? childNode)) - { - value = childNode; - declaredType = typeof(JsonNode); - return true; - } - - return false; - } - - if (source is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(propertyName, out JsonElement childElement)) - { - value = childElement; - declaredType = typeof(JsonElement); - return true; - } - - if (source is IDictionary dictionary && dictionary.Contains(propertyName)) - { - value = dictionary[propertyName]; - declaredType = value?.GetType(); - return true; - } - - try - { - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(source.GetType()); - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null && - !IsExcludedReadOnlyMember(property, serializerOptions)) - { - value = property.Get(source); - declaredType = property.PropertyType; - propertyInfo = property; - return true; - } - } - } - catch (NotSupportedException) - { - // Without serializer metadata, keep the serialized snapshot as the source of truth. - } - - return false; - } - private static bool TrySerializeSourceValue( object? value, Type? declaredType, JsonSerializerOptions serializerOptions, JsonPropertyInfo? propertyInfo, + JsonNumberHandling? numberHandling, out JsonNode? serializedValue) { serializedValue = null; @@ -1830,26 +1991,27 @@ private static bool TrySerializeSourceValue( { JsonSerializerOptions effectiveOptions = serializerOptions; JsonConverter? propertyConverter = GetPropertyConverter(propertyInfo, valueType); - if (propertyConverter is not null || propertyInfo?.NumberHandling is not null) + if (propertyConverter is not null || numberHandling is not null) { effectiveOptions = new JsonSerializerOptions(serializerOptions); if (propertyConverter is not null) { effectiveOptions.Converters.Insert(0, propertyConverter); } - - if (propertyInfo?.NumberHandling is { } numberHandling) - { - effectiveOptions.NumberHandling = numberHandling; - } } JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); + if (numberHandling is { } effectiveNumberHandling) + { + // Changing global options would also change nested objects and custom converters. + typeInfo.NumberHandling = effectiveNumberHandling; + } + JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); return true; } - catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) { return false; } @@ -1862,8 +2024,7 @@ private static bool TrySerializeSourceValue( return null; } - PropertyInfo? customConverterProperty = typeof(JsonPropertyInfo).GetProperty("CustomConverter", BindingFlags.Instance | BindingFlags.Public); - if (customConverterProperty?.GetValue(propertyInfo) is JsonConverter customConverter) + if (propertyInfo.CustomConverter is JsonConverter customConverter) { return customConverter; } @@ -1883,6 +2044,7 @@ private sealed class SchemaReferenceResolver { private readonly Dictionary _resources = new(); private readonly Dictionary _resourceIds = new(); + private readonly List _schemas = []; internal SchemaReferenceResolver(JsonNode schema) { @@ -1907,6 +2069,60 @@ internal JsonNode GetResourceRoot(JsonNode schema) return resource; } + internal static List EnterResource(List scope, JsonNode resource) => + scope.Contains(resource) ? scope : [.. scope, resource]; + + internal string GetScopeKey(List scope) => + string.Join("\n", scope.Select(resource => _resourceIds[resource].AbsoluteUri)); + + internal void BindDynamicReferences(JsonNode document, JsonNode copy, List scope) + { + // JsonSchemaNode exposes evaluation metadata, but EvaluationContext cannot be + // constructed with a caller scope. Bind only names already fixed by that scope: + // entering further resources cannot change their outermost dynamic anchor. + foreach (JsonNode node in _schemas) + { + if (node is not JsonObject schemaObject || + schemaObject["$dynamicRef"] is not JsonValue value || + !value.TryGetValue(out string? reference) || + reference is null || + !TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out _) || + target is not JsonObject targetObject || + targetObject["$dynamicAnchor"]?.GetValue() is not string anchor || + reference.IndexOf('#') < 0 || + !string.Equals(Uri.UnescapeDataString(reference[(reference.IndexOf('#') + 1)..]), anchor, StringComparison.Ordinal)) + { + continue; + } + + foreach (JsonNode resource in scope) + { + if (TryFindSchemaAnchor(resource, anchor, out JsonNode? bound, dynamicOnly: true) && + bound is not null && + TryGetNodeAtPath(copy, GetSchemaPointer(node, document), out JsonNode? copiedNode) && + copiedNode is JsonObject copiedObject) + { + copiedObject.Remove("$dynamicRef"); + if (copiedObject.ContainsKey("$ref")) + { + JsonArray allOf = copiedObject["allOf"] as JsonArray ?? []; + if (allOf.Parent is null) + { + copiedObject["allOf"] = allOf; + } + + allOf.Add((JsonNode)new JsonObject { ["$ref"] = GetSchemaUri(bound).AbsoluteUri }); + } + else + { + copiedObject["$ref"] = GetSchemaUri(bound).AbsoluteUri; + } + break; + } + } + } + } + private void Register(JsonNode? schema, Uri baseUri, bool isRoot = false) { if (schema is not JsonObject schemaObject) @@ -1914,6 +2130,7 @@ private void Register(JsonNode? schema, Uri baseUri, bool isRoot = false) return; } + _schemas.Add(schema); bool isResource = isRoot; if (schemaObject["$id"] is JsonValue idValue && idValue.TryGetValue(out string? id) && Uri.TryCreate(baseUri, id, out Uri? resourceUri)) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index f39e11658..6b547b372 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -29,7 +29,14 @@ JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions) { IgnoreReadOnlyProperties = true, - TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver), + TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) + .WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(AotBaseOutput)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + } + }), }; McpServerTool structuredTool = McpServerTool.Create( () => new AotStructuredOutput(), @@ -42,7 +49,7 @@ { "$id":"https://example.test/aot", "type":"object", - "required":["id","note","value","items","genericItems","dynamicItems","unions"], + "required":["id","note","value","items","genericItems","dynamicItems","unions","numbers","extension","contract","nullable"], "anyOf":[{"required":["count"]},{"required":["note"]}], "$defs":{"child":{"$id":"child","type":"object","required":["count"],"properties":{"count":{"const":0}}}}, "properties":{ @@ -50,6 +57,10 @@ "items":{"type":"object","additionalProperties":{"$ref":"child"}}, "genericItems":{"type":"object","additionalProperties":{"$ref":"child"}}, "dynamicItems":{"type":"object","additionalProperties":{"$ref":"child"}}, + "numbers":{"required":["count"],"properties":{"count":{"const":"0"}}}, + "extension":{"properties":{"child/~":{"required":["note"]}}}, + "contract":{"oneOf":[{"required":["flag"]},{"required":["note"]}]}, + "nullable":{"required":["note"]}, "unions":{"type":"array","items":{"oneOf":[ {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} @@ -102,6 +113,11 @@ structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0 || structured.GetProperty("genericItems").GetProperty("42").GetProperty("count").GetInt32() != 0 || structured.GetProperty("dynamicItems").GetProperty("entry").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("numbers").GetProperty("count").GetString() != "0" || + structured.GetProperty("extension").GetProperty("child/~").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("contract").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("contract").TryGetProperty("flag", out _) || + structured.GetProperty("nullable").GetProperty("note").ValueKind != JsonValueKind.Null || structured.TryGetProperty("count", out _)) { throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}"); @@ -188,6 +204,14 @@ internal sealed class AotStructuredOutput public AotUnionOutput[] Unions { get; init; } = Enumerable.Range(0, 5).Select(_ => new AotUnionOutput()).ToArray(); + public AotNumberOutput Numbers { get; init; } = new(); + public AotExtensionOutput Extension { get; init; } = new(); + public AotBaseOutput Contract { get; init; } = new AotDerivedOutput(); + public AotNullableChild? Nullable { get; init; } = new AotNullableChild(); + + [JsonConverter(typeof(AotRejectNullConverter))] + public string? Optional { get; init; } + private static ExpandoObject CreateDynamicItems() { ExpandoObject items = new(); @@ -196,6 +220,39 @@ private static ExpandoObject CreateDynamicItems() } } +[JsonNumberHandling(JsonNumberHandling.WriteAsString)] +internal sealed class AotNumberOutput +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } +} + +internal sealed class AotExtensionOutput +{ + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child/~"] = new AotNullableChild() }; +} + +internal class AotBaseOutput +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool Flag { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } +} + +internal sealed class AotDerivedOutput : AotBaseOutput; + +internal sealed class AotRejectNullConverter : JsonConverter +{ + public override bool HandleNull => true; + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => + throw new ArgumentNullException(nameof(value)); +} + internal sealed class AotUnionOutput { public string Tag { get; init; } = "0"; @@ -241,4 +298,5 @@ public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSeriali } [JsonSerializable(typeof(AotStructuredOutput))] +[JsonSerializable(typeof(AotDerivedOutput))] internal partial class AotJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs new file mode 100644 index 000000000..29a532486 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using ModelContextProtocol.Tests.Utils; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Fact] + public async Task StructuredOutput_Search_ComposesIndependentExclusiveChoicesAtSamePath() + { + var output = new + { + A0 = (string?)null, + B0 = (string?)null, + A1 = (string?)null, + B1 = (string?)null, + A2 = (string?)null, + B2 = (string?)null, + A3 = (string?)null, + B3 = (string?)null, + A4 = (string?)null, + B4 = (string?)null, + }; + JsonArray allOf = []; + for (int i = 0; i < 5; i++) + { + allOf.Add(JsonNode.Parse($$"""{"oneOf":[{"required":["a{{i}}"]},{"required":["b{{i}}"]}]}""")); + } + + McpServerTool tool = CreateStructuredOutputTestTool(output, new JsonObject { ["allOf"] = allOf }.ToJsonString()); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + for (int i = 0; i < 5; i++) + { + Assert.NotEqual(result.TryGetProperty($"a{i}", out _), result.TryGetProperty($"b{i}", out _)); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Search_PreservesCallerScopeForBranchValidation(bool dynamicReference, bool siblingReference) + { + string reference = dynamicReference ? "\"$dynamicRef\":\"#entry\"" : "\"$ref\":\"https://example.test/root#entry\""; + if (siblingReference) + { + reference += ",\"$ref\":\"#/$defs/childRule\""; + } + + string schema = $$$$""" + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.test/root", "$dynamicAnchor":"entry", + "required":["note"], + "properties":{"items":{"items":{"$ref":"base"}}}, + "$defs":{"base":{ + "$id":"base","$dynamicAnchor":"entry", + "$defs":{"childRule":{"required":["marker"]}}, + "oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{ {{{{reference}}}} }},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{ {{{{reference}}}} }},"additionalProperties":false} + ] + }} + } + """; + var output = new + { + Note = (string?)null, + Items = Enumerable.Range(0, 5).Select(_ => new + { + Tag = "0", + Good = (string?)null, + Bad = (string?)null, + Child = new { Note = (string?)null, Marker = (string?)null }, + }).ToArray(), + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + foreach (JsonElement item in result.GetProperty("items").EnumerateArray()) + { + Assert.Equal(JsonValueKind.Null, item.GetProperty("good").ValueKind); + Assert.False(item.TryGetProperty("bad", out _)); + Assert.Equal(JsonValueKind.Null, item.GetProperty("child").GetProperty("note").ValueKind); + Assert.Equal(siblingReference, item.GetProperty("child").TryGetProperty("marker", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Search_RepairsLargeMixedTagArray() + { + const string schema = """ + {"type":"array","items":{"oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true},"additionalProperties":false} + ]}} + """; + var output = Enumerable.Range(0, 10_000).Select(i => new + { + Tag = (i % 2).ToString(System.Globalization.CultureInfo.InvariantCulture), + Good = (string?)null, + Bad = (string?)null, + }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(output.Length, result.GetArrayLength()); + for (int i = 0; i < output.Length; i++) + { + Assert.Equal(JsonValueKind.Null, result[i].GetProperty(i % 2 == 0 ? "good" : "bad").ValueKind); + Assert.False(result[i].TryGetProperty(i % 2 == 0 ? "bad" : "good", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Search_DistinguishesDynamicCallersForSharedBranches() + { + const string schema = """ + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.test/root", + "properties":{"left":{"$ref":"left"},"right":{"$ref":"right"}}, + "$defs":{ + "left":{"$id":"left","$dynamicAnchor":"entry","required":["leftNote"], + "properties":{"items":{"items":{"$ref":"base"}}}}, + "right":{"$id":"right","$dynamicAnchor":"entry","required":["rightNote"], + "properties":{"items":{"items":{"$ref":"base"}}}}, + "base":{"$id":"base","$dynamicAnchor":"entry","oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false} + ]} + } + } + """; + var items = Enumerable.Range(0, 5).Select(_ => new + { + Tag = "0", + Good = (string?)null, + Bad = (string?)null, + Child = new { LeftNote = (string?)null, RightNote = (string?)null }, + }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(new + { + Left = new { LeftNote = (string?)null, Items = items }, + Right = new { RightNote = (string?)null, Items = items }, + }, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + foreach (string side in new[] { "left", "right" }) + { + foreach (JsonElement item in result.GetProperty(side).GetProperty("items").EnumerateArray()) + { + JsonElement child = item.GetProperty("child"); + Assert.Equal(JsonValueKind.Null, child.GetProperty(side + "Note").ValueKind); + Assert.False(child.TryGetProperty(side == "left" ? "rightNote" : "leftNote", out _)); + Assert.False(item.TryGetProperty("bad", out _)); + } + } + } + + [Theory] + [InlineData(14, true)] + [InlineData(25, false)] + public async Task StructuredOutput_Search_TraversesSharedReferenceDagOnce(int depth, bool sourceHasValue) + { + string property = sourceHasValue ? "note" : "absent"; + JsonObject definitions = new() { ["s0"] = JsonNode.Parse($$"""{"required":["{{property}}"]}""") }; + for (int i = 1; i <= depth; i++) + { + definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}"""); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null }, + new JsonObject { ["$ref"] = $"#/$defs/s{depth}", ["$defs"] = definitions }.ToJsonString()); + JsonElement result = await Task.Run( + () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + if (sourceHasValue) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + // Detailed diagnostics duplicate results for every successful DAG branch. + JsonSchema schema = JsonSchema.Build( + tool.ProtocolTool.OutputSchema!.Value, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); + Assert.True(schema.Evaluate(result, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid); + } + else + { + Assert.False(result.TryGetProperty("absent", out _)); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 043085baa..b6af2963b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -2163,6 +2163,503 @@ private sealed class ThrowingGetterOutput public string? Note { get; init; } } + [Theory] + [InlineData("property", false)] + [InlineData("list", false)] + [InlineData("dictionary", false)] + [InlineData("property", true)] + [InlineData("list", true)] + [InlineData("dictionary", true)] + [InlineData("object", false)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesActiveSerializationContract(string shape, bool polymorphic) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBase)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + if (polymorphic) + { + typeInfo.PolymorphismOptions = new() + { + DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") }, + }; + } + } + }); + options.TypeInfoResolver = resolver; + RecoveryDerived child = new(); + object output = shape switch + { + "list" => new List { child }, + "dictionary" => new Dictionary { ["child"] = child }, + "object" => new { Child = (object)child }, + _ => new RecoveryContainer { Child = child }, + }; + string schema = shape == "list" + ? """{"items":{"required":["flag"]}}""" + : """{"properties":{"child":{"required":["flag"]}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + JsonElement entry = shape == "list" ? result[0] : result.GetProperty("child"); + Assert.Equal(polymorphic || shape == "object", entry.TryGetProperty("flag", out _)); + if (polymorphic || shape == "object") + { + Assert.False(entry.GetProperty("flag").GetBoolean()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeUnrelatedOmittedMembers() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), + """{"required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("optional", out _)); + } + + [Theory] + [InlineData("argument")] + [InlineData("format")] + [InlineData("application")] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenRequiredNullConverterThrows(string failure) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = failure }); + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterCancellation() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = "cancel" }); + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options); + await Assert.ThrowsAnyAsync(() => InvokeStructuredOutputTestTool(tool)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesNullableAnnotationPolicy() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.RespectNullableAnnotations = true; + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryNonNullableOutput(), """{"required":["note"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesContainingNumberHandling(bool propertyOverride) + { + object output = propertyOverride ? new RecoveryStrictNumberOutput() : new RecoveryStringNumberOutput(); + string schema = propertyOverride + ? """{"required":["count"],"properties":{"count":{"const":0}}}""" + : """{"required":["count"],"properties":{"count":{"const":"0"}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInheritNumberHandlingIntoNestedObjects() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryNestedNumberOutput(), + """{"required":["child"],"properties":{"child":{"properties":{"count":{"const":0}}}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotOverrideCustomConverterNumberHandling(bool propertyConverter) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.NumberHandling = JsonNumberHandling.Strict; + if (!propertyConverter) + { + options.Converters.Insert(0, new RecoveryNumberPolicyConverter()); + } + + object output = propertyConverter ? new RecoveryCustomNumberOutput() : new RecoveryStringNumberOutput(); + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"required":["count"],"properties":{"count":{"const":true}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversFlattenedExtensionData() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryExtensionOutput { Data = new() { ["child/~"] = new NestedDefaultedStructuredChild() } }, + """{"required":["child/~"],"properties":{"child/~":{"required":["note"]}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child/~").GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("data", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRepeatedlyEnumerateSource(bool indexed) + { + const int count = 256; + RecoveryCountingCollection source = indexed ? new RecoveryCountingList(count) : new(count); + McpServerTool tool = CreateStructuredOutputTestTool( + source, """{"items":{"required":["note"]}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversConditionalMetadata(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.IgnoreReadOnlyProperties = true; + options.TypeInfoResolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver(); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryConditionalOutput(), """{"required":["note","count","readOnlyCount"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(0, result.GetProperty("readOnlyCount").GetInt32()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverWriteExcludedMetadata(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + IJsonTypeInfoResolver resolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryConditionalOutput(), """{"required":["excluded"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("excluded", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverOutsideDeclaredInterface() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new { Child = (IRecoveryContract)new RecoveryDerived() }, + """{"properties":{"child":{"required":["flag"]}}}""", + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("flag", out _)); + } + + [Theory] + [InlineData("property")] + [InlineData("list")] + [InlineData("dictionary")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinNullableStructContract(string shape) + { + NestedDefaultedStructuredChild? child = new NestedDefaultedStructuredChild(); + object output = shape switch + { + "list" => new List { child }, + "dictionary" => new Dictionary { ["child"] = child }, + _ => new { Child = child }, + }; + string schema = shape == "list" + ? """{"items":{"required":["note"]}}""" + : """{"properties":{"child":{"required":["note"]}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesSerializerDefaultComparison(bool globalCondition) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions( + globalCondition ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull); + object output = globalCondition ? new RecoveryGlobalEquatableOutput() : new RecoveryEquatableOutput(); + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"required":["child"],"properties":{"child":{"required":["note"]}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(JsonUnknownDerivedTypeHandling.FallBackToBaseType, false)] + [InlineData(JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPolymorphicFallbackContract( + JsonUnknownDerivedTypeHandling fallback, bool expectedFlag) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBase)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + typeInfo.PolymorphismOptions = new() + { + UnknownDerivedTypeHandling = fallback, + DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") }, + }; + } + }, + }, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryContainer { Child = new RecoveryGrandchild() }, + """{"properties":{"child":{"required":["flag"]}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(expectedFlag, result.GetProperty("child").TryGetProperty("flag", out _)); + if (expectedFlag) + { + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotTraverseOpaqueConverter(bool propertyConverter) + { + object output = propertyConverter + ? new RecoveryOpaquePropertyOutput() + : new { Child = new RecoveryOpaqueTypeOutput() }; + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"properties":{"child":{"required":["flag"]}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("flag", out _)); + Assert.Equal("visible", result.GetProperty("child").GetProperty("id").GetString()); + } + + private interface IRecoveryContract; + + private class RecoveryBase + { + public bool Flag { get; init; } + } + + private class RecoveryDerived : RecoveryBase, IRecoveryContract; + + private sealed class RecoveryGrandchild : RecoveryDerived; + + private sealed class RecoveryContainer + { + public RecoveryBase Child { get; init; } = new RecoveryDerived(); + } + + private sealed class OptionalThrowingConverterOutput + { + public string? Note { get; init; } + + [JsonConverter(typeof(RecoveryRejectNullConverter))] + public string? Optional { get; init; } + } + + private sealed class RecoveryNonNullableOutput + { + public string Note { get; init; } = null!; + } + + private sealed class RecoveryRejectNullConverter : JsonConverter + { + public string Failure { get; init; } = "argument"; + public override bool HandleNull => true; + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + if (value is not null) + { + writer.WriteStringValue(value); + return; + } + + throw Failure switch + { + "format" => new FormatException("Null is not supported."), + "application" => new ApplicationException("Null is not supported."), + "cancel" => new OperationCanceledException(), + _ => new ArgumentNullException(nameof(value)), + }; + } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryStringNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryStrictNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.Strict)] + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryNestedNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryNumberChild Child { get; init; } + } + + private struct RecoveryNumberChild + { + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryCustomNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(RecoveryNumberPolicyConverter))] + public int Count { get; init; } + } + + private sealed class RecoveryNumberPolicyConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteBooleanValue(options.NumberHandling == JsonNumberHandling.Strict); + } + + private sealed class RecoveryExtensionOutput + { + [JsonExtensionData] + public Dictionary Data { get; init; } = []; + } + + private sealed class RecoveryConditionalOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int ReadOnlyCount => 0; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string? Excluded { get; init; } + } + + private sealed class RecoveryEquatableOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryEquatableChild Child { get; init; } = new(1); + } + + private sealed class RecoveryGlobalEquatableOutput + { + public RecoveryEquatableChild Child { get; init; } = new(1); + } + + private readonly struct RecoveryEquatableChild(int ignored) : IEquatable + { + [JsonIgnore] + public int Ignored { get; } = ignored; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + public bool Equals(RecoveryEquatableChild other) => Note == other.Note; + } + + private class RecoveryOpaqueChild + { + public bool Flag => throw new InvalidOperationException("The converter hides this getter."); + } + + private sealed class RecoveryOpaquePropertyOutput + { + [JsonConverter(typeof(RecoveryOpaqueConverter))] + public RecoveryOpaqueChild Child { get; init; } = new(); + } + + [JsonConverter(typeof(RecoveryOpaqueConverter))] + private sealed class RecoveryOpaqueTypeOutput : RecoveryOpaqueChild; + + private sealed class RecoveryOpaqueConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeof(RecoveryOpaqueChild).IsAssignableFrom(typeToConvert); + public override RecoveryOpaqueChild Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, RecoveryOpaqueChild value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("id", "visible"); + writer.WriteEndObject(); + } + } + + private class RecoveryCountingCollection(int count) : IReadOnlyList + { + public int EnumeratedItems { get; private set; } + public int Count => count; + public NestedDefaultedStructuredChild this[int index] => default; + public IEnumerator GetEnumerator() + { + for (int i = 0; i < count; i++) + { + EnumeratedItems++; + yield return default; + } + } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private sealed class RecoveryCountingList(int count) : RecoveryCountingCollection(count), System.Collections.IList + { + object? System.Collections.IList.this[int index] { get => this[index]; set => throw new NotSupportedException(); } + public bool IsReadOnly => true; + public bool IsFixedSize => true; + public bool IsSynchronized => false; + public object SyncRoot => this; + public int Add(object? value) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(object? value) => throw new NotSupportedException(); + public int IndexOf(object? value) => throw new NotSupportedException(); + public void Insert(int index, object? value) => throw new NotSupportedException(); + public void Remove(object? value) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + public void CopyTo(Array array, int index) => throw new NotSupportedException(); + } + private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) => new(McpJsonUtilities.DefaultOptions) { @@ -3392,6 +3889,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(Person))] [JsonSerializable(typeof(WeatherRequest))] [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))] + [JsonSerializable(typeof(RecoveryConditionalOutput))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests ===== From 5b24706ff0a339f28a4244bcc3383383bc9dddb7 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 28 Oct 2026 12:54:55 -0700 Subject: [PATCH 15/30] Fix structured output recovery boundaries and branch selection --- .../Server/AIFunctionMcpServerTool.cs | 515 ++++++++++++++---- .../Program.cs | 105 +++- .../McpServerToolConditionalRepairTests.cs | 387 +++++++++++++ .../McpServerToolRecoveryBoundaryTests.cs | 459 ++++++++++++++++ 4 files changed, 1351 insertions(+), 115 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 4c19fd9f7..fc1422c53 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -736,7 +736,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - if (IsSchemaValid(schema, resultNode)) + if (IsSchemaValid(schema, resultNode) || HasDuplicatePropertyNames(result)) { return elementResult; } @@ -770,11 +770,12 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) JsonNode? schemaDocument = null, SchemaReferenceResolver? references = null, List? dynamicScope = null, - RepairTraversalBudget? traversalBudget = null) + RepairTraversalBudget? traversalBudget = null, + List? inheritedConditions = null) { // An explicit schema may deliberately omit optional nulls. Preserve that valid output // before looking for missing required members. - if (remainingDepth <= 0 || IsSchemaValid(schema, resultNode)) + if (remainingDepth <= 0) { return null; } @@ -786,8 +787,60 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) schemaDocument ??= schemaNode; references ??= new(schemaDocument); traversalBudget ??= new(); + // Cache predicates independently of their callers. Conditional expectations and + // instance paths remain on each group and are checked on every tentative repair. Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = new(); + JsonSchema? GetBranchSchema(JsonNode branch, List scope) + { + var schemaKey = (branch, references.GetScopeKey(scope)); + if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) + { + JsonNode document = schemaDocument.DeepClone(); + references.BindDynamicReferences(schemaDocument, document, scope); + if (document is JsonObject documentObject) + { + documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri; + } + + JsonObject branchDocument = new() + { + ["$ref"] = references.GetSchemaUri(branch).AbsoluteUri, + ["$defs"] = new JsonObject { ["document"] = document }, + }; + TryBuildJsonSchema(branchDocument, out branchSchema); + branchSchemas.Add(schemaKey, branchSchema); + } + + return branchSchema; + } + + bool AreConditionsValid(JsonNode instance, List conditions) + { + foreach (RepairCondition condition in conditions) + { + JsonSchema? conditionSchema = GetBranchSchema(condition.Schema, condition.DynamicScope); + if (conditionSchema is null || + !TryGetNodeAtPath(instance, condition.InstancePath, out JsonNode? conditionInstance) || + conditionInstance is null || + IsSchemaValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema)) != condition.Expected) + { + return false; + } + } + + return true; + } + + bool IsRepairValid(JsonNode instance) => + IsSchemaValid(schema, instance) && + (inheritedConditions is null || AreConditionsValid(instance, inheritedConditions)); + + if (IsRepairValid(resultNode)) + { + return null; + } + JsonNode? EnqueueRepairs(JsonNode instance, int depth) { if (depth >= remainingDepth) @@ -800,6 +853,14 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) CollectSchemaContexts( instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, new SchemaTraversal(traversalBudget), groups, references, dynamicScope); + if (inheritedConditions is { Count: > 0 }) + { + groups = groups.Select(group => group with + { + Conditions = [.. inheritedConditions, .. group.Conditions], + }).ToList(); + } + List candidates = CreateRepairCandidates( instance, originalResult, serializerOptions, contexts); if (candidates.Count == 0) @@ -833,30 +894,18 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) continue; } - var schemaKey = (group.Schema, references.GetScopeKey(group.DynamicScope)); - if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) - { - JsonNode document = schemaDocument.DeepClone(); - references.BindDynamicReferences(schemaDocument, document, group.DynamicScope); - if (document is JsonObject documentObject) - { - documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri; - } - - JsonObject branchDocument = new() - { - ["$ref"] = references.GetSchemaUri(group.Schema).AbsoluteUri, - ["$defs"] = new JsonObject { ["document"] = document }, - }; - TryBuildJsonSchema(branchDocument, out branchSchema); - branchSchemas.Add(schemaKey, branchSchema); - } - + JsonSchema? branchSchema = GetBranchSchema(group.Schema, group.DynamicScope); if (branchSchema is null) { continue; } + // A leaf must not lock an enclosing oneOf branch whose assertions fail. + // Once that ancestor has qualified, avoid revalidating its entire subtree. + List unqualifiedAncestors = group.Choices.Where(choice => + !(ReferenceEquals(choice.Branch, group.Schema) && choice.InstancePath == group.InstancePath) && + !exclusiveChoices.ContainsKey((choice.Applicator, choice.InstancePath))).ToList(); + // Compose independently valid subtree repairs before searching whole-document subsets. List groupCandidates = []; foreach (string key in group.Keys) @@ -871,7 +920,8 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { List branchCandidates = includeDescendants ? groupCandidates - : groupCandidates.Where(candidate => candidate.ParentPath == group.InstancePath).ToList(); + : groupCandidates.Where(candidate => + candidate.ParentPath == group.InstancePath && group.DirectKeys.Contains(candidate.Key)).ToList(); JsonNode branchResult = currentBranch.DeepClone(); foreach (RepairCandidate candidate in branchCandidates) { @@ -879,7 +929,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } bool branchIsValid = IsSchemaValid(branchSchema, branchResult); - if (!branchIsValid && includeDescendants && branchCandidates.Count > 0 && depth + 1 < remainingDepth) + if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth) { SourceRecoveryContext? branchSource = null; if (originalResult is not null) @@ -890,26 +940,67 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) if (RepairStructuredResult( branchResult, group.Schema, branchSchema, branchSource, serializerOptions, remainingDepth - depth - 1, schemaDocument, references, - group.DynamicScope, traversalBudget) is JsonNode repairedBranch) + group.DynamicScope, traversalBudget, + group.Conditions.Where(condition => + condition.InstancePath == group.InstancePath || + condition.InstancePath.StartsWith(group.InstancePath + "/", StringComparison.Ordinal)) + .Select(condition => condition with + { + InstancePath = condition.InstancePath.Substring(group.InstancePath.Length), + }).ToList()) is JsonNode repairedBranch) { branchResult = repairedBranch; branchIsValid = true; } } + if (branchIsValid && (group.Conditions.Count > 0 || unqualifiedAncestors.Count > 0)) + { + // Conditions belong to the caller's instance and dynamic scope, which + // may differ from the nested branch being repaired. Test the tentative + // replacement, not the original instance or an isolated leaf schema. + JsonNode tentativeResult = branchResult; + if (group.InstancePath.Length > 0) + { + ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult); + tentativeResult = combinedResult; + } + + try + { + branchIsValid = AreConditionsValid(tentativeResult, group.Conditions); + foreach (RepairExclusiveChoice ancestor in unqualifiedAncestors) + { + if (!branchIsValid) + { + break; + } + + JsonSchema? ancestorSchema = GetBranchSchema(ancestor.Branch, ancestor.DynamicScope); + branchIsValid = ancestorSchema is not null && + TryGetNodeAtPath(tentativeResult, ancestor.InstancePath, out JsonNode? ancestorInstance) && + ancestorInstance is not null && + IsSchemaValid(ancestorSchema, ancestorInstance); + } + } + finally + { + if (group.InstancePath.Length > 0) + { + ReplaceRepairBranch(branchResult, group.InstancePath, currentBranch); + } + } + } + if (branchIsValid) { if (group.InstancePath.Length == 0) { combinedResult = branchResult; } - else if (currentBranch.Parent is JsonArray parentArray) - { - parentArray[parentArray.IndexOf(currentBranch)] = branchResult; - } - else if (currentBranch.Parent is JsonObject parentObject) + else { - parentObject[parentObject.First(property => ReferenceEquals(property.Value, currentBranch)).Key] = branchResult; + ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult); } hasCombinedRepairs = true; @@ -927,7 +1018,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { // The common case composes independent repairs. Avoid allocating subset queues // (or repeatedly scanning the full candidate list) when that result already works. - if (IsSchemaValid(schema, combinedResult)) + if (IsRepairValid(combinedResult)) { return combinedResult; } @@ -994,7 +1085,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } evaluations++; - if (IsSchemaValid(schema, candidateResult)) + if (IsRepairValid(candidateResult)) { return candidateResult; } @@ -1123,11 +1214,68 @@ private sealed record RepairCandidate(string ParentPath, string PropertyName, Js private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); - private sealed record RepairExclusiveChoice(JsonNode Applicator, JsonNode Branch, string InstancePath); + private sealed record RepairExclusiveChoice( + JsonNode Applicator, JsonNode Branch, string InstancePath, List DynamicScope); + + private sealed record RepairCondition( + JsonNode Schema, string InstancePath, List DynamicScope, bool Expected); + + private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode schema) + { + if (instance is not JsonObject instanceObject || schema is not JsonObject schemaObject || + schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type"))) + { + return instance; + } + + // These keywords cannot observe unrelated properties. In particular, do not + // copy a large sibling collection for every small descendant repair. + JsonObject projection = new(); + if (schemaObject["properties"] is JsonObject properties) + { + foreach (var property in properties) + { + if (instanceObject.TryGetPropertyValue(property.Key, out JsonNode? value)) + { + projection[property.Key] = value?.DeepClone(); + } + } + } + + if (schemaObject["required"] is JsonArray required) + { + foreach (JsonNode? item in required) + { + if (item is JsonValue name && name.TryGetValue(out string? propertyName) && + propertyName is not null && !projection.ContainsKey(propertyName) && + instanceObject.ContainsKey(propertyName)) + { + projection[propertyName] = null; + } + } + } + + return projection; + } private sealed record RepairCandidateGroup( - HashSet Keys, string InstancePath, JsonNode Schema, - List DynamicScope, List Choices); + HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema, + List DynamicScope, List Choices, List Conditions); + + private static void ReplaceRepairBranch(JsonNode currentBranch, string instancePath, JsonNode replacement) + { + string segment = DecodeJsonPointerSegment(instancePath.Substring(instancePath.LastIndexOf('/') + 1)); + if (currentBranch.Parent is JsonArray parentArray) + { + parentArray[int.Parse(segment, NumberStyles.None, CultureInfo.InvariantCulture)] = replacement; + } + else if (currentBranch.Parent is JsonObject parentObject) + { + parentObject[segment] = replacement; + } + } + + private static string DecodeJsonPointerSegment(string segment) => segment.Replace("~1", "/").Replace("~0", "~"); private sealed class RepairTraversalBudget { @@ -1447,12 +1595,16 @@ private static void CollectSchemaContexts( { if (schemaObject["then"] is JsonNode thenSchema) { - CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope); + CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + condition: new(ifSchema, instancePath, + SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), true)); } if (schemaObject["else"] is JsonNode elseSchema) { - CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope); + CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + condition: new(ifSchema, instancePath, + SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), false)); } } @@ -1491,20 +1643,18 @@ private static void CollectSchemaBranch( List groups, SchemaReferenceResolver references, List dynamicScope, - JsonNode? exclusiveApplicator = null) + JsonNode? exclusiveApplicator = null, + RepairCondition? condition = null) { Dictionary branchContexts = new(StringComparer.Ordinal); List branchGroups = []; CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, branchContexts, traversal, branchGroups, references, dynamicScope); + List branchScope = SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)); List choices = exclusiveApplicator is null - ? [] : [new(exclusiveApplicator, branch, instancePath)]; - foreach (RepairCandidateGroup branchGroup in branchGroups) - { - groups.Add(branchGroup with { Choices = [.. choices, .. branchGroup.Choices] }); - } - + ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)]; + List conditions = condition is null ? [] : [condition]; HashSet group = new(StringComparer.Ordinal); foreach (KeyValuePair entry in branchContexts) { @@ -1517,8 +1667,20 @@ private static void CollectSchemaBranch( if (group.Count > 0) { - groups.Add(new(group, instancePath, branch, - SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)), choices)); + HashSet directKeys = new(group, StringComparer.Ordinal); + directKeys.ExceptWith(branchGroups.SelectMany(static nested => nested.Keys)); + // Restore this branch's own discriminator before composing nested selectors. + // Nested alternatives must not be added en masse before re-evaluating them. + groups.Add(new(group, directKeys, instancePath, branch, branchScope, choices, conditions)); + } + + foreach (RepairCandidateGroup branchGroup in branchGroups) + { + groups.Add(branchGroup with + { + Choices = [.. choices, .. branchGroup.Choices], + Conditions = [.. conditions, .. branchGroup.Conditions], + }); } } @@ -1554,7 +1716,7 @@ private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? foreach (string rawSegment in path.Substring(1).Split('/')) { - string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + string segment = DecodeJsonPointerSegment(rawSegment); if (value is JsonObject objectValue && objectValue.TryGetPropertyValue(segment, out JsonNode? objectChild)) { value = objectChild; @@ -1597,7 +1759,7 @@ parent is null || return false; } - return TrySerializeSourceValue(member.Value, member.Contract.Type, serializerOptions, property, member.NumberHandling, out value); + return TrySerializeSourceMember(member, out value); } private static bool WasOmittedBySupportedIgnoreCondition( @@ -1646,12 +1808,26 @@ propertyInfo is not null && .FirstOrDefault() : null; - private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) => - propertyInfo.ShouldSerialize is null && GetIgnoreAttribute(propertyInfo) is null && propertyInfo.Set is null && - (propertyInfo.AttributeProvider is FieldInfo - ? serializerOptions.IgnoreReadOnlyFields - : serializerOptions.IgnoreReadOnlyProperties && - serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary)); + private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) + { + if (propertyInfo.ShouldSerialize is not null || GetIgnoreAttribute(propertyInfo) is not null || propertyInfo.Set is not null) + { + return false; + } + + if (propertyInfo.AttributeProvider is FieldInfo) + { + return serializerOptions.IgnoreReadOnlyFields; + } + + if (propertyInfo.AttributeProvider is not PropertyInfo && serializerOptions.IgnoreReadOnlyFields) + { + return true; + } + + return serializerOptions.IgnoreReadOnlyProperties && + serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary); + } // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) @@ -1706,8 +1882,8 @@ JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.In } private sealed class SourceRecoveryContext( - object? value, JsonTypeInfo contract, JsonNumberHandling? numberHandling = null, - JsonPropertyInfo? property = null, object? owner = null) + object? value, JsonTypeInfo contract, JsonPropertyInfo? property = null, + object? owner = null, JsonTypeInfo? ownerContract = null) { private readonly Dictionary _children = new(StringComparer.Ordinal); private List? _items; @@ -1715,9 +1891,9 @@ private sealed class SourceRecoveryContext( internal object? Value { get; } = value; internal JsonTypeInfo Contract { get; } = contract; - internal JsonNumberHandling? NumberHandling { get; } = numberHandling; internal JsonPropertyInfo? Property { get; } = property; internal object? Owner { get; } = owner; + internal JsonTypeInfo? OwnerContract { get; } = ownerContract; internal SourceRecoveryContext? GetChild(string name, JsonNode? serializedParent) { @@ -1749,25 +1925,48 @@ private sealed class SourceRecoveryContext( JsonSerializerOptions options = Contract.Options; JsonTypeInfo typeInfo = GetActiveContract(); + if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) && + serializedParent is JsonObject referenceObject) + { + if (referenceObject.ContainsKey("$ref")) + { + return null; + } + + if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && name == "$values" && + referenceObject["$values"] is JsonArray) + { + return new(Value, typeInfo); + } + } + if (typeInfo.Kind == JsonTypeInfoKind.Dictionary && serializedParent is JsonObject serializedDictionary && Value is IEnumerable entries) { if (!_dictionaryRead) { _dictionaryRead = true; - using IEnumerator> properties = serializedDictionary.GetEnumerator(); + JsonTypeInfo keyContract = options.GetTypeInfo(typeInfo.KeyType!); foreach (object entry in entries) { - if (!properties.MoveNext()) + if (!TryGetDictionaryEntry(entry, out object? entryKey, out object? entryValue)) { - break; + continue; } - if (TryGetDictionaryEntryValue(entry, out object? entryValue)) + // Non-generic dictionaries dispatch non-string keys through the object converter. + JsonTypeInfo entryKeyContract = Value is IDictionary && keyContract.Type == typeof(string) && entryKey is not string + ? options.GetTypeInfo(typeof(object)) + : keyContract; + if (!TrySerializeDictionaryKey(entryKey, entryKeyContract, out string serializedKey) || + !serializedDictionary.ContainsKey(serializedKey)) { - _children[properties.Current.Key] = new( - entryValue, options.GetTypeInfo(typeInfo.ElementType!), NumberHandling ?? typeInfo.NumberHandling); + continue; } + + _children[serializedKey] = _children.ContainsKey(serializedKey) + ? null + : new(entryValue, options.GetTypeInfo(typeInfo.ElementType!)); } } @@ -1811,7 +2010,7 @@ private sealed class SourceRecoveryContext( item = _items[index]; } - return new(item, options.GetTypeInfo(typeInfo.ElementType!), NumberHandling ?? typeInfo.NumberHandling); + return new(item, options.GetTypeInfo(typeInfo.ElementType!)); } if (typeInfo.Kind != JsonTypeInfoKind.Object) @@ -1835,9 +2034,7 @@ private sealed class SourceRecoveryContext( object? memberValue = member.Get(Value); JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); - return new(memberValue, memberContract, - GetEffectiveNumberHandling(member, typeInfo, memberContract), - member, Value); + return new(memberValue, memberContract, member, Value, typeInfo); } } @@ -1873,29 +2070,6 @@ private sealed class SourceRecoveryContext( return null; } - private static JsonNumberHandling? GetEffectiveNumberHandling( - JsonPropertyInfo property, JsonTypeInfo declaringType, JsonTypeInfo memberType) - { - if (property.CustomConverter is not null || memberType.Converter.GetType().Assembly != typeof(JsonSerializer).Assembly) - { - return null; - } - - Type type = memberType.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary - ? memberType.ElementType! - : memberType.Type; - type = Nullable.GetUnderlyingType(type) ?? type; - bool supportsNumberHandling = !type.IsEnum && (type == typeof(object) || - Type.GetTypeCode(type) is TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or TypeCode.UInt16 or - TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64 or - TypeCode.Single or TypeCode.Double or TypeCode.Decimal || - (type.Assembly == typeof(int).Assembly && type.FullName is "System.Half" or "System.Int128" or "System.UInt128")); - - return supportsNumberHandling - ? property.NumberHandling ?? declaringType.NumberHandling ?? memberType.NumberHandling - : null; - } - private JsonTypeInfo GetActiveContract() { JsonSerializerOptions options = Contract.Options; @@ -1957,11 +2131,12 @@ private JsonTypeInfo GetActiveContract() #endif [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( "Trimming", "IL2075", - Justification = "The reflected property belongs to KeyValuePair<,>, whose public properties are explicitly preserved.")] - private static bool TryGetDictionaryEntryValue(object entry, out object? value) + Justification = "The reflected properties belong to KeyValuePair<,>, whose public properties are explicitly preserved.")] + private static bool TryGetDictionaryEntry(object entry, out object? key, out object? value) { if (entry is DictionaryEntry dictionaryEntry) { + key = dictionaryEntry.Key; value = dictionaryEntry.Value; return true; } @@ -1969,45 +2144,129 @@ private static bool TryGetDictionaryEntryValue(object entry, out object? value) Type type = entry.GetType(); if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) { + key = type.GetProperty(nameof(KeyValuePair.Key))!.GetValue(entry); value = type.GetProperty(nameof(KeyValuePair.Value))!.GetValue(entry); return true; } - value = null; + key = value = null; return false; } - private static bool TrySerializeSourceValue( - object? value, - Type? declaredType, - JsonSerializerOptions serializerOptions, - JsonPropertyInfo? propertyInfo, - JsonNumberHandling? numberHandling, - out JsonNode? serializedValue) +#if !NETSTANDARD2_0 + [System.Diagnostics.CodeAnalysis.DynamicDependency( + nameof(JsonConverter.WriteAsPropertyName), typeof(JsonConverter<>))] +#endif + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2075", + Justification = "The reflected method belongs to JsonConverter, whose WriteAsPropertyName method is explicitly preserved.")] + private static bool TrySerializeDictionaryKey(object? key, JsonTypeInfo contract, out string name) + { + name = string.Empty; + try + { + if (key is string stringValue && contract.Type == typeof(string) && + contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) + { + name = contract.Options.DictionaryKeyPolicy is { } policy ? policy.ConvertName(stringValue) : stringValue; + if (name is not null) + { + return true; + } + + name = string.Empty; + return false; + } + + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + if (contract.Converter is JsonConverter stringConverter && key is string stringKey) + { + stringConverter.WriteAsPropertyName(writer, stringKey, contract.Options); + } + else if (contract.Converter is JsonConverter objectConverter && key is not null) + { + objectConverter.WriteAsPropertyName(writer, key, contract.Options); + } + else + { + Type? converterType = contract.Converter.GetType(); + while (converterType is not null && + (!converterType.IsGenericType || converterType.GetGenericTypeDefinition() != typeof(JsonConverter<>))) + { + converterType = converterType.BaseType; + } + + converterType?.GetMethod(nameof(JsonConverter.WriteAsPropertyName))? + .Invoke(contract.Converter, [writer, key, contract.Options]); + } + + writer.WriteNullValue(); + writer.WriteEndObject(); + } + + using JsonDocument document = JsonDocument.Parse(stream.ToArray()); + name = document.RootElement.EnumerateObject().Single().Name; + return true; + } + catch (TargetInvocationException exception) when (exception.InnerException is + OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); + throw; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return false; + } + } + + private static bool TrySerializeSourceMember(SourceRecoveryContext member, out JsonNode? serializedValue) { serializedValue = null; - Type valueType = declaredType ?? value?.GetType() ?? typeof(object); try { - JsonSerializerOptions effectiveOptions = serializerOptions; - JsonConverter? propertyConverter = GetPropertyConverter(propertyInfo, valueType); - if (propertyConverter is not null || numberHandling is not null) + JsonTypeInfo originalContract = member.OwnerContract!; + JsonSerializerOptions options = originalContract.Options; + // Ask the resolver for uncached metadata so converters still receive the original + // options and their delegated serialization uses the unchanged cached contracts. + JsonTypeInfo? contract = options.TypeInfoResolver?.GetTypeInfo(originalContract.Type, options); + if (contract is not { Kind: JsonTypeInfoKind.Object, IsReadOnly: false } || + ReferenceEquals(contract, originalContract)) { - effectiveOptions = new JsonSerializerOptions(serializerOptions); - if (propertyConverter is not null) + return false; + } + + JsonPropertyInfo property = member.Property!; + foreach (JsonPropertyInfo candidate in contract.Properties) + { + if (candidate.Name == property.Name && candidate.PropertyType == property.PropertyType) + { + candidate.Get = _ => member.Value; + candidate.ShouldSerialize = static (_, _) => true; + candidate.CustomConverter = GetPropertyConverter(property, property.PropertyType); + candidate.NumberHandling = property.NumberHandling; + candidate.IsGetNullable = property.IsGetNullable; + } + else { - effectiveOptions.Converters.Insert(0, propertyConverter); + candidate.Get = null; } } - JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); - if (numberHandling is { } effectiveNumberHandling) + contract.NumberHandling = originalContract.NumberHandling; + contract.PolymorphismOptions = null; + contract.OnSerializing = null; + contract.OnSerialized = null; + JsonElement element = JsonSerializer.SerializeToElement(member.Owner, contract).GetProperty(property.Name); + if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) && + ContainsReferenceMetadata(element)) { - // Changing global options would also change nested objects and custom converters. - typeInfo.NumberHandling = effectiveNumberHandling; + return false; } - JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); return true; } @@ -2017,6 +2276,36 @@ private static bool TrySerializeSourceValue( } } + private static bool ContainsReferenceMetadata(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject().Any(static property => + property.Name is "$id" or "$ref" || ContainsReferenceMetadata(property.Value)), + JsonValueKind.Array => element.EnumerateArray().Any(static item => ContainsReferenceMetadata(item)), + _ => false, + }; + + private static bool HasDuplicatePropertyNames(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + HashSet names = new(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name) || HasDuplicatePropertyNames(property.Value)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + return element.EnumerateArray().Any(static item => HasDuplicatePropertyNames(item)); + } + + return false; + } + private static JsonConverter? GetPropertyConverter(JsonPropertyInfo? propertyInfo, Type propertyType) { if (propertyInfo is null) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 6b547b372..be9ccafc1 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; @@ -6,6 +7,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Collections; using System.Collections.ObjectModel; using System.Dynamic; using System.IO.Pipelines; @@ -49,7 +51,7 @@ { "$id":"https://example.test/aot", "type":"object", - "required":["id","note","value","items","genericItems","dynamicItems","unions","numbers","extension","contract","nullable"], + "required":["id","note","value","items","genericItems","dynamicItems","unions","numbers","extension","contract","nullable","delegatingCount"], "anyOf":[{"required":["count"]},{"required":["note"]}], "$defs":{"child":{"$id":"child","type":"object","required":["count"],"properties":{"count":{"const":0}}}}, "properties":{ @@ -61,6 +63,7 @@ "extension":{"properties":{"child/~":{"required":["note"]}}}, "contract":{"oneOf":[{"required":["flag"]},{"required":["note"]}]}, "nullable":{"required":["note"]}, + "delegatingCount":{"const":0}, "unions":{"type":"array","items":{"oneOf":[ {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} @@ -69,10 +72,33 @@ } """).RootElement, }); +JsonSerializerOptions preservedOptions = new(structuredOptions) { ReferenceHandler = ReferenceHandler.Preserve }; +McpServerTool preservedTool = McpServerTool.Create( + new AotPreservedFunction(preservedOptions), + new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(""" + { + "required":["entry/1~","entry/2~"], + "properties":{ + "entry/1~":{"required":["count"],"properties":{"count":{"type":"integer"}}}, + "entry/2~":{"required":["count"],"properties":{"count":{"type":"integer"}}} + } + } + """).RootElement, + }); +McpServerTool preservedNongenericTool = McpServerTool.Create( + new AotPreservedFunction(preservedOptions, nongeneric: true), + new() + { + UseStructuredContent = true, + OutputSchema = preservedTool.ProtocolTool.OutputSchema, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([structuredTool]) + .WithTools([structuredTool, preservedTool, preservedNongenericTool]) .WithMcpApps() .WithSkills(catalog) .WithResources(authoredSkill.Resources); @@ -118,6 +144,7 @@ structured.GetProperty("contract").GetProperty("note").ValueKind != JsonValueKind.Null || structured.GetProperty("contract").TryGetProperty("flag", out _) || structured.GetProperty("nullable").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("delegatingCount").GetInt32() != 0 || structured.TryGetProperty("count", out _)) { throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}"); @@ -132,6 +159,17 @@ } } +foreach (string name in new[] { "PreservedDictionary", "PreservedNongenericDictionary" }) +{ + var preservedResult = await client.CallToolAsync(name); + if (preservedResult.StructuredContent is not { } preserved || + preserved.GetProperty("entry/1~").GetProperty("count").GetInt32() != 0 || + preserved.GetProperty("entry/2~").GetProperty("count").GetInt32() != 5) + { + throw new Exception($"Unexpected preserved dictionary output: {preservedResult.StructuredContent}"); + } +} + // List, get, and verify a skill. var skills = await client.ListSkillsAsync(); if (skills.Count != 1 || skills[0].Name != "aot") @@ -209,6 +247,10 @@ internal sealed class AotStructuredOutput public AotBaseOutput Contract { get; init; } = new AotDerivedOutput(); public AotNullableChild? Nullable { get; init; } = new AotNullableChild(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(AotDelegatingIntConverter))] + public int DelegatingCount { get; init; } + [JsonConverter(typeof(AotRejectNullConverter))] public string? Optional { get; init; } @@ -297,6 +339,65 @@ public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSeriali writer.WriteStringValue("converted"); } +internal sealed class AotDelegatingIntConverter : JsonConverter +{ + private bool _writing; + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (_writing) + { + throw new InvalidOperationException("Recursive member converter invocation."); + } + + _writing = true; + try + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int))); + } + finally + { + _writing = false; + } + } +} + +[JsonConverter(typeof(AotDictionaryKeyConverter))] +internal readonly record struct AotDictionaryKey(int Value); + +internal sealed class AotDictionaryKeyConverter : JsonConverter +{ + public override AotDictionaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + public override void WriteAsPropertyName(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) => + writer.WritePropertyName($"entry/{value.Value}~"); +} + +internal sealed class AotPreservedFunction(JsonSerializerOptions options, bool nongeneric = false) : AIFunction +{ + private readonly object _value = nongeneric + ? new Hashtable + { + [new AotDictionaryKey(1)] = default(AotStructuredChild), + [new AotDictionaryKey(2)] = new AotStructuredChild { Count = 5 }, + } + : new Dictionary + { + [new(1)] = default, + [new(2)] = new() { Count = 5 }, + }; + public override string Name => nongeneric ? "PreservedNongenericDictionary" : "PreservedDictionary"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(_value); +} + [JsonSerializable(typeof(AotStructuredOutput))] [JsonSerializable(typeof(AotDerivedOutput))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Hashtable))] internal partial class AotJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs new file mode 100644 index 000000000..6d157c727 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs @@ -0,0 +1,387 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Fact] + public async Task StructuredOutput_ConditionalRepair_LeavesExactContainsSearchExhaustionUnchanged() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "contains":{ + "if":{"properties":{"tag":{"const":0}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + }, + "minContains":64, + "maxContains":64 + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + // Exact-count subset search remains bounded; never emit an invalid partial repair. + Assert.Equal(129, result.GetArrayLength()); + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(1, item.GetProperty("tag").GetInt32()); + Assert.False(item.TryGetProperty("a", out _)); + Assert.False(item.TryGetProperty("b", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ComposesSiblingChoicesBeforeQualifyingAncestor() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new + { + Tag = 1, + Left = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + Right = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + }).ToArray(), + """ + { + "items":{"oneOf":[ + {"properties":{"tag":{"const":0},"left":{"$ref":"#/$defs/zero"},"right":{"$ref":"#/$defs/zero"}},"required":["tag"]}, + {"properties":{"tag":{"const":1},"left":{"$ref":"#/$defs/one"},"right":{"$ref":"#/$defs/one"}},"required":["tag"]} + ]}, + "$defs":{ + "zero":{"oneOf":[{"required":["a0"]},{"required":["b0"]}]}, + "one":{"oneOf":[{"required":["a1"]},{"required":["b1"]}]} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + foreach (string name in new[] { "left", "right" }) + { + JsonElement child = item.GetProperty(name); + Assert.False(child.TryGetProperty("a0", out _)); + Assert.False(child.TryGetProperty("b0", out _)); + Assert.NotEqual(child.TryGetProperty("a1", out _), child.TryGetProperty("b1", out _)); + } + }); + } + + [Theory] + [InlineData("""{"properties":{"tag":{"const":1}}}""")] + [InlineData("""{"type":"object","properties":{"tag":{"const":1}},"required":["marker","items"]}""")] + [InlineData("""{"properties":{"tag":{"const":1}},"minProperties":3}""")] + public async Task StructuredOutput_ConditionalRepair_ValidatesAncestorGuardWithoutLosingRequiredProperties(string guard) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new + { + Tag = 1, + Marker = "present", + Items = Enumerable.Range(0, 1024).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray(), + }, + """ + { + "if":GUARD, + "then":{"properties":{"items":{"items":{"oneOf":[ + {"required":["a"],"not":{"required":["b"]}}, + {"required":["absent"]} + ]}}}}, + "else":{"properties":{"items":{"items":{"required":["b"],"not":{"required":["a"]}}}}} + } + """.Replace("GUARD", guard)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.GetProperty("items").EnumerateArray(), item => + { + Assert.True(item.TryGetProperty("a", out _)); + Assert.False(item.TryGetProperty("b", out _)); + }); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_ConditionalRepair_QualifiesAncestorChoices(bool omitTag, bool nestedChild) + { + int tag = omitTag ? 0 : 1; + var items = Enumerable.Range(0, 129).Select(_ => new + { + Tag = tag, + A0 = (string?)null, + B0 = (string?)null, + A1 = (string?)null, + B1 = (string?)null, + Child = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + }).ToArray(); + JsonObject CreateBranch(int branchTag, string first, string second) + { + JsonArray alternatives = [ + new JsonObject { ["required"] = new JsonArray(first) }, + new JsonObject { ["required"] = new JsonArray(second) }, + ]; + JsonObject properties = new() { ["tag"] = new JsonObject { ["const"] = branchTag } }; + JsonObject branch = new() { ["properties"] = properties, ["required"] = new JsonArray("tag") }; + if (nestedChild) + { + properties["child"] = new JsonObject { ["oneOf"] = alternatives }; + } + else + { + branch["oneOf"] = alternatives; + } + + return branch; + } + + JsonObject schema = new() + { + ["items"] = new JsonObject + { + ["oneOf"] = new JsonArray(CreateBranch(1 - tag, "a0", "b0"), CreateBranch(tag, "a1", "b1")), + }, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + items, schema.ToJsonString(), CreateStructuredOutputTestOptions( + omitTag ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(tag, item.GetProperty("tag").GetInt32()); + JsonElement value = nestedChild ? item.GetProperty("child") : item; + Assert.False(value.TryGetProperty("a0", out _)); + Assert.False(value.TryGetProperty("b0", out _)); + Assert.NotEqual(value.TryGetProperty("a1", out _), value.TryGetProperty("b1", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ReevaluatesInsideOneOfAfterRequiredRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "items":{"oneOf":[ + { + "required":["tag"], + "if":{"properties":{"tag":{"const":1}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + }, + {"required":["absent"]} + ]} + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_PreservesGuardsDuringRecursiveRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Child = default(ConditionalRepairChild) }).ToArray(), + """ + { + "items":{ + "if":{"properties":{"child":{"required":["b"]}}}, + "then":{ + "required":["child"], + "properties":{"child":{"oneOf":[ + {"required":["a"],"not":{"required":["b"]}}, + {"required":["b"],"not":{"required":["a"]}} + ]}} + }, + "else":false + } + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.GetProperty("child").TryGetProperty("b", out _)); + Assert.False(item.GetProperty("child").TryGetProperty("a", out _)); + }); + } + + [Theory] + [InlineData(129, false, false)] + [InlineData(129, false, true)] + [InlineData(1024, false, true)] + [InlineData(1024, true, true)] + public async Task StructuredOutput_ConditionalRepair_ComposesOnlyMatchingBranches(int count, bool dictionary, bool mixed) + { + var items = Enumerable.Range(0, count) + .Select(index => new { Tag = mixed ? index % 2 : 1, A = (string?)null, B = (string?)null }).ToArray(); + object output = dictionary + ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/~1/{pair.index}~", pair => pair.item) + : items; + string container = dictionary ? "\"additionalProperties\"" : "\"items\""; + McpServerTool tool = CreateStructuredOutputTestTool(output, $$$""" + { + {{{container}}}:{ + "if":{"properties":{"tag":{"const":0} } }, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + IEnumerable repaired = dictionary + ? result.EnumerateObject().Select(property => property.Value) + : result.EnumerateArray(); + Assert.Equal(count, repaired.Count()); + foreach (JsonElement item in repaired) + { + bool zero = item.GetProperty("tag").GetInt32() == 0; + Assert.Equal(JsonValueKind.Null, item.GetProperty(zero ? "a" : "b").ValueKind); + Assert.False(item.TryGetProperty(zero ? "b" : "a", out _)); + } + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ReevaluatesAfterRequiredRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "items":{ + "required":["tag"], + "if":{"properties":{"tag":{"const":1}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_ConditionalRepair_PreservesAncestorGuardsThroughOneOf(bool conditionalInsideOneOf) + { + const string conditional = """ + { + "if":{"properties":{"tag":{"const":0}}}, + "then":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/a"},{"required":["absent"]}]}}}, + "else":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/b"},{"required":["absent"]}]}}} + } + """; + string entrySchema = conditionalInsideOneOf + ? $$"""{"oneOf":[{"allOf":[{{conditional}}],"required":["tag"]},{"required":["absent"]}]}""" + : conditional; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, Child = new { A = (string?)null, B = (string?)null } }).ToArray(), + $$$""" + { + "items":{{{entrySchema}}}, + "$defs":{ + "a":{"required":["a"],"not":{"required":["b"]}}, + "b":{"required":["b"],"not":{"required":["a"]}} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.GetProperty("child").TryGetProperty("b", out _)); + Assert.False(item.GetProperty("child").TryGetProperty("a", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_KeepsIndependentConditionsAtSamePath() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null, C = (string?)null }).ToArray(), + """ + { + "items":{"allOf":[ + {"if":{"properties":{"tag":{"const":0}}},"then":{"$ref":"#/$defs/a"},"else":{"$ref":"#/$defs/b"}}, + {"if":{"properties":{"tag":{"const":1}}},"then":{"required":["c"]},"else":{"$ref":"#/$defs/a"}} + ]}, + "$defs":{"a":{"required":["a"],"not":{"required":["b"]}},"b":{"required":["b"],"not":{"required":["a"]}}} + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.TryGetProperty("b", out _)); + Assert.True(item.TryGetProperty("c", out _)); + Assert.False(item.TryGetProperty("a", out _)); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_ConditionalRepair_UsesCallerReferenceScope(bool dynamicReference) + { + string condition = dynamicReference ? """{"$dynamicRef":"#guard"}""" : """{"$ref":"#/$defs/guard"}"""; + string anchor = dynamicReference ? "\"$dynamicAnchor\":\"guard\"," : ""; + int localTag = dynamicReference ? 1 : 0; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(), + $$$$""" + { + "$id":"https://example.test/conditional/root", + "$defs":{ + "guard":{ {{{{anchor}}}} "properties":{"tag":{"const":0} } }, + "entry":{ + "$id":"entry", + "$defs":{"guard":{ {{{{anchor}}}} "properties":{"tag":{"const":{{{{localTag}}}} } } } }, + "if":{{{{condition}}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + }, + "items":{"$ref":"entry"} + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _))); + } + + private struct ConditionalRepairChild + { + public string? A { get; set; } + + public string? B { get; set; } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs new file mode 100644 index 000000000..c6cb9a2af --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -0,0 +1,459 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public async Task StructuredOutput_RecoveryBoundary_PreservesMemberConverterScope(bool factory, bool sourceGenerated, bool globalConverter) + { + RecoveryBoundaryDelegatingConverter converter = new(); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + if (globalConverter) + { + options.Converters.Insert(0, new RecoveryBoundaryGlobalIntConverter()); + } + + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBoundaryConvertedOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.CustomConverter = factory ? new RecoveryBoundaryConverterFactory(converter) : converter; + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryConvertedOutput(), + globalConverter + ? """{"required":["count"],"properties":{"count":{"const":"global-0"},"existing":{"const":"global-5"}}}""" + : """{"required":["count"],"properties":{"count":{"const":0},"existing":{"const":5}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(converter.RecursionDetected); + Assert.False(converter.OptionsChanged); + if (globalConverter) + { + Assert.Equal("global-0", result.GetProperty("count").GetString()); + Assert.Equal("global-5", result.GetProperty("existing").GetString()); + } + else + { + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("existing").GetInt32()); + } + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_UsesSerializedDictionaryKeys(bool preserveReferences, bool reorder) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + if (preserveReferences) + { + options.ReferenceHandler = ReferenceHandler.Preserve; + } + + RecoveryBoundaryDictionary output = new(reorder); + McpServerTool tool = CreateRecoveryBoundaryTool(output, """ + { + "required":["first/~","second"], + "properties":{ + "first/~":{"required":["count"],"properties":{"count":{"type":"integer"}}}, + "second":{"required":["count"],"properties":{"count":{"type":"integer"}}} + } + } + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("first/~").GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("second").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_UsesDictionaryKeyConverter() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = ReferenceHandler.Preserve; + options.Converters.Insert(0, new RecoveryBoundaryKeyConverter()); + McpServerTool tool = CreateRecoveryBoundaryTool( + new Dictionary + { + [new(1)] = default, + [new(2)] = new() { Count = 5 }, + }, + """{"properties":{"key-1":{"required":["count"]},"key-2":{"required":["count"]}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("key-1").GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("key-2").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_UsesNongenericDictionaryKeyDispatch(bool customKey, bool preserveReferences) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.Converters.Insert(0, new RecoveryBoundaryKeyConverter()); + if (preserveReferences) + { + options.ReferenceHandler = ReferenceHandler.Preserve; + } + + Hashtable output = new() + { + [customKey ? new RecoveryBoundaryKey(1) : (object)1] = default(RecoveryBoundaryValue), + [customKey ? new RecoveryBoundaryKey(2) : (object)2] = new RecoveryBoundaryValue { Count = 5 }, + }; + string first = customKey ? "key-1" : "1"; + string second = customKey ? "key-2" : "2"; + McpServerTool tool = CreateRecoveryBoundaryTool(output, $$$$""" + {"properties":{"{{{{first}}}}":{"required":["count"]},"{{{{second}}}}":{"required":["count"]}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty(first).GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty(second).GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("root")] + [InlineData("object")] + [InlineData("array")] + public async Task StructuredOutput_RecoveryBoundary_PreservesOutputWithCollidingDictionaryKeys(string shape) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + Dictionary dictionary = new() + { + ["First"] = default, + ["first"] = new() { Count = 5 }, + }; + object output = shape switch + { + "object" => new { Child = dictionary }, + "array" => new[] { dictionary }, + _ => dictionary, + }; + string schema = shape switch + { + "object" => """{"properties":{"child":{"required":["unavailable"]}}}""", + "array" => """{"items":{"required":["unavailable"]}}""", + _ => """{"required":["unavailable"]}""", + }; + McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + result = shape switch + { + "object" => result.GetProperty("child"), + "array" => result[0], + _ => result, + }; + + Assert.Equal(2, result.EnumerateObject().Count()); + Assert.All(result.EnumerateObject(), property => Assert.Equal("first", property.Name)); + } + + [Theory] + [InlineData("original", false)] + [InlineData("original", true)] + [InlineData("missing", false)] + [InlineData("missing", true)] + [InlineData("wrapped", false)] + [InlineData("wrapped", true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRecoverExcludedReadonlyField(string provider, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.IncludeFields = true; + options.IgnoreReadOnlyFields = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (provider != "original") + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = provider == "missing" ? null : new RecoveryBoundaryAttributeProvider(); + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryReadonlyOutput(), """{"required":["count"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(result.TryGetProperty("count", out _)); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_DoesNotAddMembersToReferenceObjects() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.ReferenceHandler = ReferenceHandler.Preserve; + RecoveryBoundaryNullableOutput child = new(); + McpServerTool tool = CreateRecoveryBoundaryTool( + new { First = child, Second = child }, + """{"properties":{"first":{"required":["note"]},"second":{"required":["note"]}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonProperty reference = Assert.Single(result.GetProperty("second").EnumerateObject()); + Assert.Equal("$ref", reference.Name); + Assert.False(result.GetProperty("first").TryGetProperty("note", out _)); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_TraversesPreservedCollectionValues() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.ReferenceHandler = ReferenceHandler.Preserve; + McpServerTool tool = CreateRecoveryBoundaryTool( + new List { new() }, + """{"properties":{"$values":{"items":{"required":["note"]}}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("$values")[0].GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_DoesNotIntroduceIndependentReferenceIds() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = ReferenceHandler.Preserve; + McpServerTool tool = CreateRecoveryBoundaryTool( + new { Existing = new RecoveryBoundaryNullableOutput(), Missing = default(RecoveryBoundaryReferenceValue) }, + """{"required":["missing"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(result.TryGetProperty("missing", out _)); + Assert.True(result.GetProperty("existing").TryGetProperty("$id", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRepeatOwnerCallbacksOrUnrelatedGetters(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + + RecoveryBoundaryCallbackOutput output = new(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["count"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(2, output.SerializingCalls); + Assert.Equal(2, output.SerializedCalls); + Assert.Equal(2, output.GetterCalls); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_PreservesCompatibleSourceGeneratedContracts() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryAttributedOutput(), + """{"required":["count"],"properties":{"count":{"const":0}}}""", + RecoveryBoundaryJsonContext.Default.Options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + private sealed class RecoveryBoundaryConvertedOutput + { + public int Existing { get; set; } = 5; + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryDelegatingConverter : JsonConverter + { + private bool _writing; + private JsonSerializerOptions? _originalOptions; + + public bool RecursionDetected { get; private set; } + public bool OptionsChanged { get; private set; } + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (_writing) + { + RecursionDetected = true; + throw new InvalidOperationException("Recursive member converter invocation."); + } + + _originalOptions ??= options; + OptionsChanged |= !ReferenceEquals(_originalOptions, options); + _writing = true; + try + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int))); + } + finally + { + _writing = false; + } + } + } + + private sealed class RecoveryBoundaryConverterFactory(RecoveryBoundaryDelegatingConverter converter) : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(int); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => converter; + } + + private sealed class RecoveryBoundaryGlobalIntConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteStringValue($"global-{value}"); + } + + private struct RecoveryBoundaryValue + { + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryDictionary(bool reorder) : IReadOnlyDictionary + { + private readonly Dictionary _entries = new() + { + ["First/~"] = default, + ["Second"] = new() { Count = 5 }, + }; + private int _enumerations; + + public RecoveryBoundaryValue this[string key] => _entries[key]; + public IEnumerable Keys => _entries.Keys; + public IEnumerable Values => _entries.Values; + public int Count => _entries.Count; + public bool ContainsKey(string key) => _entries.ContainsKey(key); + public bool TryGetValue(string key, out RecoveryBoundaryValue value) => _entries.TryGetValue(key, out value); + public IEnumerator> GetEnumerator() => + (reorder && ++_enumerations % 2 == 0 ? _entries.Reverse() : _entries).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private readonly record struct RecoveryBoundaryKey(int Value); + + private sealed class RecoveryBoundaryKeyConverter : JsonConverter + { + public override RecoveryBoundaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) => + writer.WriteStringValue($"value-{value.Value}"); + public override void WriteAsPropertyName(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) => + writer.WritePropertyName($"key-{value.Value}"); + } + + private sealed class RecoveryBoundaryReadonlyOutput + { + public readonly int Count = 0; + } + + private sealed class RecoveryBoundaryAttributeProvider : ICustomAttributeProvider + { + public object[] GetCustomAttributes(bool inherit) => []; + public object[] GetCustomAttributes(Type attributeType, bool inherit) => []; + public bool IsDefined(Type attributeType, bool inherit) => false; + } + + private sealed class RecoveryBoundaryAttributedOutput + { + [JsonConverter(typeof(RecoveryBoundaryDelegatingConverter))] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryNullableOutput + { + public string? Note { get; set; } + } + + private struct RecoveryBoundaryReferenceValue + { + public RecoveryBoundaryNullableOutput Child => new(); + } + + private sealed class RecoveryBoundaryCallbackOutput : IJsonOnSerializing, IJsonOnSerialized + { + public int Count { get; set; } + public int Existing { get { GetterCalls++; return 5; } } + [JsonIgnore] + public int SerializingCalls { get; private set; } + [JsonIgnore] + public int SerializedCalls { get; private set; } + [JsonIgnore] + public int GetterCalls { get; private set; } + public void OnSerializing() => SerializingCalls++; + public void OnSerialized() => SerializedCalls++; + } + + private static McpServerTool CreateRecoveryBoundaryTool(object value, string schema, JsonSerializerOptions options) => + McpServerTool.Create(new RecoveryBoundaryFunction(value, options), new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(schema).RootElement, + }); + + private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOptions options) : AIFunction + { + public override string Name => "tool"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(value); + } + + [JsonSourceGenerationOptions(IncludeFields = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(RecoveryBoundaryConvertedOutput))] + [JsonSerializable(typeof(RecoveryBoundaryReadonlyOutput))] + [JsonSerializable(typeof(RecoveryBoundaryCallbackOutput))] + [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))] + [JsonSerializable(typeof(object))] + private partial class RecoveryBoundaryJsonContext : JsonSerializerContext; +} From 8731f218c9e9a615277d9df6a92a109e9a08afd8 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 28 Oct 2026 14:06:16 -0700 Subject: [PATCH 16/30] Cache structured output recovery contracts without mutating metadata --- .../Server/AIFunctionMcpServerTool.cs | 123 ++++++++---- .../Program.cs | 12 +- .../McpServerToolRecoveryBoundaryTests.cs | 189 ++++++++++++++++++ 3 files changed, 281 insertions(+), 43 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index fc1422c53..17a69c970 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -3,10 +3,12 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using System.Collections; +using System.Collections.Concurrent; using System.ComponentModel; using System.Globalization; using System.Diagnostics; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -20,6 +22,9 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; private OutputSchemaCache? _outputSchema; + private static readonly ConditionalWeakTable s_sourceContracts = new(); + [ThreadStatic] + private static SourceRecoveryContext? t_serializingSourceMember; /// /// Creates an instance for a method, specified via a instance. @@ -2018,26 +2023,20 @@ private sealed class SourceRecoveryContext( return null; } - JsonPropertyInfo? extensionData = null; - foreach (JsonPropertyInfo member in typeInfo.Properties) + SourceObjectContract sourceContract = s_sourceContracts.GetValue(typeInfo, static contract => new(contract)); + if (sourceContract.Properties.TryGetValue(name, out JsonPropertyInfo? member)) { - if (member.IsExtensionData) + if (member.Get is null || IsExcludedReadOnlyMember(member, options)) { - extensionData = member; + return null; } - else if (string.Equals(member.Name, name, StringComparison.Ordinal)) - { - if (member.Get is null || IsExcludedReadOnlyMember(member, options)) - { - return null; - } - object? memberValue = member.Get(Value); - JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); - return new(memberValue, memberContract, member, Value, typeInfo); - } + object? memberValue = member.Get(Value); + JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); + return new(memberValue, memberContract, member, Value, typeInfo); } + JsonPropertyInfo? extensionData = sourceContract.ExtensionData; if (extensionData?.Get is not { } getExtensionData || extensionData.CustomConverter is not null || IsExcludedReadOnlyMember(extensionData, options)) { @@ -2125,6 +2124,62 @@ private JsonTypeInfo GetActiveContract() } } + private sealed class SourceObjectContract + { + private readonly JsonTypeInfo _contract; + private readonly ConcurrentDictionary>> _serializationContracts = new(); + + internal Dictionary Properties { get; } = new(StringComparer.Ordinal); + internal JsonPropertyInfo? ExtensionData { get; } + + internal SourceObjectContract(JsonTypeInfo contract) + { + _contract = contract; + foreach (JsonPropertyInfo property in contract.Properties) + { + if (property.IsExtensionData) + { + ExtensionData = property; + } + else + { + Properties.Add(property.Name, property); + } + } + } + + internal JsonTypeInfo GetSerializationContract(JsonPropertyInfo property) => + _serializationContracts.GetOrAdd(property, member => new(() => CreateSerializationContract(member))).Value; + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2026", + Justification = "The property type metadata is cached in the original options before CreateJsonPropertyInfo reuses its typed property factory.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Aot", "IL3050", + Justification = "CreateJsonPropertyInfo uses the cached property type metadata, so no new generic types are constructed.")] + private JsonTypeInfo CreateSerializationContract(JsonPropertyInfo property) + { + JsonSerializerOptions options = _contract.Options; + _ = options.GetTypeInfo(property.PropertyType); + // Reuse the owner's object converter, not its frozen metadata. The object-typed + // adapter still serializes the actual owner, preserving cycle detection. + JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(options, _contract.Converter); + contract.NumberHandling = _contract.NumberHandling; + contract.PolymorphismOptions = null; + contract.OnSerializing = null; + contract.OnSerialized = null; + JsonPropertyInfo candidate = contract.CreateJsonPropertyInfo(property.PropertyType, property.Name); + candidate.Get = static _ => t_serializingSourceMember!.Value; + candidate.ShouldSerialize = static (_, _) => true; + candidate.CustomConverter = GetPropertyConverter(property, property.PropertyType); + candidate.NumberHandling = property.NumberHandling; + candidate.IsGetNullable = property.IsGetNullable; + contract.Properties.Add(candidate); + contract.MakeReadOnly(); + return contract; + } + } + #if !NETSTANDARD2_0 [System.Diagnostics.CodeAnalysis.DynamicDependency( System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties, typeof(KeyValuePair<,>))] @@ -2230,37 +2285,23 @@ private static bool TrySerializeSourceMember(SourceRecoveryContext member, out J { JsonTypeInfo originalContract = member.OwnerContract!; JsonSerializerOptions options = originalContract.Options; - // Ask the resolver for uncached metadata so converters still receive the original - // options and their delegated serialization uses the unchanged cached contracts. - JsonTypeInfo? contract = options.TypeInfoResolver?.GetTypeInfo(originalContract.Type, options); - if (contract is not { Kind: JsonTypeInfoKind.Object, IsReadOnly: false } || - ReferenceEquals(contract, originalContract)) + JsonPropertyInfo property = member.Property!; + JsonTypeInfo contract = s_sourceContracts.GetValue(originalContract, static owner => new(owner)) + .GetSerializationContract(property); + JsonElement element; + SourceRecoveryContext? previous = t_serializingSourceMember; + try { - return false; + // Serialization is synchronous; restore the slot for nested invocations. + t_serializingSourceMember = member; + // Value-type serialization must not track the original box as a reference cycle. + object? owner = originalContract.Type.IsValueType ? RuntimeHelpers.GetObjectValue(member.Owner) : member.Owner; + element = JsonSerializer.SerializeToElement(owner!, contract).GetProperty(property.Name); } - - JsonPropertyInfo property = member.Property!; - foreach (JsonPropertyInfo candidate in contract.Properties) + finally { - if (candidate.Name == property.Name && candidate.PropertyType == property.PropertyType) - { - candidate.Get = _ => member.Value; - candidate.ShouldSerialize = static (_, _) => true; - candidate.CustomConverter = GetPropertyConverter(property, property.PropertyType); - candidate.NumberHandling = property.NumberHandling; - candidate.IsGetNullable = property.IsGetNullable; - } - else - { - candidate.Get = null; - } + t_serializingSourceMember = previous; } - - contract.NumberHandling = originalContract.NumberHandling; - contract.PolymorphismOptions = null; - contract.OnSerializing = null; - contract.OnSerialized = null; - JsonElement element = JsonSerializer.SerializeToElement(member.Owner, contract).GetProperty(property.Name); if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) && ContainsReferenceMetadata(element)) { diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index be9ccafc1..cbce375af 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -31,14 +31,14 @@ JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions) { IgnoreReadOnlyProperties = true, - TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) + TypeInfoResolver = new AotCachingResolver(JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) .WithAddedModifier(typeInfo => { if (typeInfo.Type == typeof(AotBaseOutput)) { typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; } - }), + })), }; McpServerTool structuredTool = McpServerTool.Create( () => new AotStructuredOutput(), @@ -396,6 +396,14 @@ internal sealed class AotPreservedFunction(JsonSerializerOptions options, bool n new(_value); } +internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver +{ + private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new(); + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => + _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!); +} + [JsonSerializable(typeof(AotStructuredOutput))] [JsonSerializable(typeof(AotDerivedOutput))] [JsonSerializable(typeof(Dictionary))] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs index c6cb9a2af..ffbd4461b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -5,11 +5,138 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_SupportsCachedReadonlyMetadata(bool sourceGenerated, bool record) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + RecoveryBoundaryCachingResolver resolver = new( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + options.TypeInfoResolver = resolver; + object output = record ? new RecoveryBoundaryNullableRecord() : new RecoveryBoundaryNullableOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options); + JsonTypeInfo original = options.GetTypeInfo(output.GetType()); + Func? originalGetter = original.Properties.Single().Get; + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.True(original.IsReadOnly); + Assert.Same(original, resolver.GetTypeInfo(original.Type, options)); + Assert.Same(originalGetter, original.Properties.Single().Get); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRebuildWideContracts(bool cacheMetadata) + { + const int width = 32; + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + int ownerResolutions = 0; + IJsonTypeInfoResolver resolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBoundaryValue)) + { + Interlocked.Increment(ref ownerResolutions); + typeInfo.Properties.Clear(); + for (int i = 0; i < width; i++) + { + JsonPropertyInfo property = typeInfo.CreateJsonPropertyInfo(typeof(int), $"count{i}"); + property.Get = static value => ((RecoveryBoundaryValue)value).Count; + typeInfo.Properties.Add(property); + } + } + }); + options.TypeInfoResolver = cacheMetadata ? new RecoveryBoundaryCachingResolver(resolver) : resolver; + string required = string.Join(",", Enumerable.Range(0, width).Select(i => $"\"count{i}\"")); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryValue[8], $$$$"""{"items":{"required":[{{{{required}}}}]}}""", options); + options.MakeReadOnly(); + JsonSerializer.SerializeToElement(new RecoveryBoundaryValue[8], options.GetTypeInfo(typeof(RecoveryBoundaryValue[]))); + int resolutionsBeforeRecovery = ownerResolutions; + + for (int invocation = 0; invocation < 2; invocation++) + { + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(width, item.EnumerateObject().Count()); + Assert.All(item.EnumerateObject(), property => Assert.Equal(0, property.Value.GetInt32())); + }); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + Assert.Equal(resolutionsBeforeRecovery, ownerResolutions); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate() + { + using Barrier barrier = new(2); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(new DefaultJsonTypeInfoResolver()); + options.Converters.Add(new RecoveryBoundaryInvocationConverter(barrier, TestContext.Current.CancellationToken)); + McpServerTool[] tools = [CreateTool(1), CreateTool(2)]; + + JsonElement[] results = await Task.WhenAll(tools.Select(tool => + Task.Run(() => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken))); + + Assert.Equal(1, results[0].GetProperty("value").GetInt32()); + Assert.Equal(2, results[1].GetProperty("value").GetInt32()); + + McpServerTool CreateTool(int value) => CreateStructuredOutputTestTool( + new RecoveryBoundaryInvocationOutput { Value = new(value) }, """{"required":["value"]}""", options); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_PreservesOwnerCycleScope(bool valueTypeOwner, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + object output = valueTypeOwner ? new RecoveryBoundaryCyclicStructOwner() : new RecoveryBoundaryCyclicOwner(); + RecoveryBoundaryCyclicValue value = new() { Owner = output }; + if (output is RecoveryBoundaryCyclicOwner referenceOwner) + { + referenceOwner.Missing = value; + } + else + { + typeof(RecoveryBoundaryCyclicStructOwner).GetProperty(nameof(RecoveryBoundaryCyclicStructOwner.Missing))!.SetValue(output, value); + } + McpServerTool tool = CreateRecoveryBoundaryTool(output, """{"required":["missing"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement owner = result.GetProperty("missing").GetProperty("owner"); + if (valueTypeOwner) + { + Assert.Equal(JsonValueKind.Object, owner.ValueKind); + Assert.Empty(owner.EnumerateObject()); + } + else + { + Assert.Equal(JsonValueKind.Null, owner.ValueKind); + } + } + [Theory] [InlineData(false, false, false)] [InlineData(false, true, false)] @@ -414,6 +541,64 @@ private sealed class RecoveryBoundaryNullableOutput public string? Note { get; set; } } + private sealed record RecoveryBoundaryNullableRecord(string? Note = null); + + private sealed class RecoveryBoundaryInvocationOutput + { + public RecoveryBoundaryInvocationValue Value { get; set; } + } + + private readonly struct RecoveryBoundaryInvocationValue(int value) : IEquatable + { + public int Value { get; } = value; + public bool Equals(RecoveryBoundaryInvocationValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryInvocationValue; + public override int GetHashCode() => 0; + } + + private sealed class RecoveryBoundaryInvocationConverter(Barrier barrier, CancellationToken cancellationToken) : JsonConverter + { + public override RecoveryBoundaryInvocationValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, RecoveryBoundaryInvocationValue value, JsonSerializerOptions options) + { + if (!barrier.SignalAndWait(TestConstants.DefaultTimeout, cancellationToken)) + { + throw new TimeoutException("Concurrent recovery did not enter both converters."); + } + writer.WriteNumberValue(value.Value); + } + } + + private sealed class RecoveryBoundaryCyclicOwner + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryBoundaryCyclicValue Missing { get; set; } + } + + private struct RecoveryBoundaryCyclicStructOwner + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryBoundaryCyclicValue Missing { get; set; } + } + + private struct RecoveryBoundaryCyclicValue : IEquatable + { + public object? Owner { get; set; } + public bool Equals(RecoveryBoundaryCyclicValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryCyclicValue; + public override int GetHashCode() => 0; + } + + private sealed class RecoveryBoundaryCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver + { + private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new(); + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => + _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!); + } + private struct RecoveryBoundaryReferenceValue { public RecoveryBoundaryNullableOutput Child => new(); @@ -454,6 +639,10 @@ private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOption [JsonSerializable(typeof(RecoveryBoundaryReadonlyOutput))] [JsonSerializable(typeof(RecoveryBoundaryCallbackOutput))] [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))] [JsonSerializable(typeof(object))] private partial class RecoveryBoundaryJsonContext : JsonSerializerContext; } From d4c38b249a38d3f8eb1c312f38d10e9895047f48 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 28 Oct 2026 14:58:20 -0700 Subject: [PATCH 17/30] Preserve ancestor cycle scope during structured output recovery --- .../Server/AIFunctionMcpServerTool.cs | 112 ++++++++-- .../Program.cs | 84 +++++++- .../McpServerToolRecoveryBoundaryTests.cs | 198 +++++++++++++++++- 3 files changed, 367 insertions(+), 27 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d494d711c..f5b863fd3 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -25,7 +25,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool private OutputSchemaCache? _outputSchema; private static readonly ConditionalWeakTable s_sourceContracts = new(); [ThreadStatic] - private static SourceRecoveryContext? t_serializingSourceMember; + private static SourceMemberSerializationState? t_serializingSourceMember; /// /// Creates an instance for a method, specified via a instance. @@ -1986,9 +1986,11 @@ JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.In private sealed class SourceRecoveryContext( object? value, JsonTypeInfo contract, JsonPropertyInfo? property = null, - object? owner = null, JsonTypeInfo? ownerContract = null) + object? owner = null, JsonTypeInfo? ownerContract = null, SourceRecoveryContext? parent = null) { private readonly Dictionary _children = new(StringComparer.Ordinal); + private Dictionary, JsonTypeInfo>? _scopeContracts; + private JsonTypeInfo? _activeContract; private List? _items; private bool _dictionaryRead; @@ -1997,6 +1999,7 @@ private sealed class SourceRecoveryContext( internal JsonPropertyInfo? Property { get; } = property; internal object? Owner { get; } = owner; internal JsonTypeInfo? OwnerContract { get; } = ownerContract; + internal SourceRecoveryContext? Parent { get; } = parent; internal SourceRecoveryContext? GetChild(string name, JsonNode? serializedParent) { @@ -2039,7 +2042,7 @@ private sealed class SourceRecoveryContext( if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && name == "$values" && referenceObject["$values"] is JsonArray) { - return new(Value, typeInfo); + return new(Value, typeInfo, parent: this); } } @@ -2069,7 +2072,7 @@ private sealed class SourceRecoveryContext( _children[serializedKey] = _children.ContainsKey(serializedKey) ? null - : new(entryValue, options.GetTypeInfo(typeInfo.ElementType!)); + : new(entryValue, options.GetTypeInfo(typeInfo.ElementType!), parent: this); } } @@ -2113,7 +2116,7 @@ private sealed class SourceRecoveryContext( item = _items[index]; } - return new(item, options.GetTypeInfo(typeInfo.ElementType!)); + return new(item, options.GetTypeInfo(typeInfo.ElementType!), parent: this); } if (typeInfo.Kind != JsonTypeInfoKind.Object) @@ -2131,7 +2134,7 @@ private sealed class SourceRecoveryContext( object? memberValue = member.Get(Value); JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); - return new(memberValue, memberContract, member, Value, typeInfo); + return new(memberValue, memberContract, member, Value, typeInfo, this); } JsonPropertyInfo? extensionData = sourceContract.ExtensionData; @@ -2156,18 +2159,48 @@ private sealed class SourceRecoveryContext( object? extensionEntry; if (extensionValue is IDictionary objectData && objectData.TryGetValue(name, out extensionEntry)) { - return new(extensionEntry, options.GetTypeInfo(extensionType.ElementType!)); + return new(extensionEntry, options.GetTypeInfo(extensionType.ElementType!), parent: this); } if (extensionValue is IDictionary elementData && elementData.TryGetValue(name, out JsonElement elementEntry)) { - return new(elementEntry, options.GetTypeInfo(typeof(JsonElement))); + return new(elementEntry, options.GetTypeInfo(typeof(JsonElement)), parent: this); } return null; } - private JsonTypeInfo GetActiveContract() + internal object GetSerializationOwner() => + GetActiveContract().Type.IsValueType ? RuntimeHelpers.GetObjectValue(Value!)! : Value!; + + internal JsonTypeInfo GetScopeContract(JsonTypeInfo childContract) + { + _scopeContracts ??= new(); + if (!_scopeContracts.TryGetValue(childContract, out JsonTypeInfo? scope)) + { + scope = CreateSerializationObjectContract(GetActiveContract()); + JsonPropertyInfo child = JsonMetadataServices.CreatePropertyInfo(scope.Options, new() + { + DeclaringType = typeof(object), + IsProperty = true, + IsPublic = true, + PropertyName = "value", + JsonPropertyName = "value", + PropertyTypeInfo = childContract, + Getter = static _ => t_serializingSourceMember!.GetNextOwner(), + }); + child.ShouldSerialize = static (_, _) => true; + scope.Properties.Add(child); + scope.MakeReadOnly(); + _scopeContracts.Add(childContract, scope); + } + + return scope; + } + + private JsonTypeInfo GetActiveContract() => _activeContract ??= ResolveActiveContract(); + + private JsonTypeInfo ResolveActiveContract() { JsonSerializerOptions options = Contract.Options; Type runtimeType = Value!.GetType(); @@ -2259,13 +2292,7 @@ private JsonTypeInfo CreateSerializationContract(JsonPropertyInfo proper { JsonSerializerOptions options = _contract.Options; _ = options.GetTypeInfo(property.PropertyType); - // Reuse the owner's object converter, not its frozen metadata. The object-typed - // adapter still serializes the actual owner, preserving cycle detection. - JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(options, _contract.Converter); - contract.NumberHandling = _contract.NumberHandling; - contract.PolymorphismOptions = null; - contract.OnSerializing = null; - contract.OnSerialized = null; + JsonTypeInfo contract = CreateSerializationObjectContract(_contract); JsonPropertyInfo candidate = contract.CreateJsonPropertyInfo(property.PropertyType, property.Name); candidate.Get = static _ => t_serializingSourceMember!.Value; candidate.ShouldSerialize = static (_, _) => true; @@ -2278,6 +2305,30 @@ private JsonTypeInfo CreateSerializationContract(JsonPropertyInfo proper } } + private static JsonTypeInfo CreateSerializationObjectContract(JsonTypeInfo original) + { + JsonConverter converter = original.Kind == JsonTypeInfoKind.Object + ? original.Converter + : JsonMetadataServices.CreateObjectInfo(original.Options, new() + { + PropertyMetadataInitializer = static _ => [], + }).Converter; + // Collections use a single-property object view to seed the same reference stack + // without enumerating unrelated entries. These ancestor envelopes are never published. + JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(original.Options, converter); + contract.NumberHandling = original.NumberHandling; + contract.PolymorphismOptions = null; + contract.OnSerializing = null; + contract.OnSerialized = null; + return contract; + } + + private sealed class SourceMemberSerializationState(object? value, Stack? owners) + { + internal object? Value { get; } = value; + internal object GetNextOwner() => owners!.Pop().GetSerializationOwner(); + } + #if !NETSTANDARD2_0 [System.Diagnostics.CodeAnalysis.DynamicDependency( System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties, typeof(KeyValuePair<,>))] @@ -2386,15 +2437,36 @@ private static bool TrySerializeSourceMember(SourceRecoveryContext member, out J JsonPropertyInfo property = member.Property!; JsonTypeInfo contract = s_sourceContracts.GetValue(originalContract, static owner => new(owner)) .GetSerializationContract(property); + SourceRecoveryContext ownerContext = member.Parent!; + Stack? owners = null; + int scopeDepth = 0; + if (ReferenceEquals(options.ReferenceHandler, ReferenceHandler.IgnoreCycles) && ownerContext.Parent is not null) + { + owners = new(); + owners.Push(ownerContext); + while (ownerContext.Parent is { } ancestor) + { + contract = ancestor.GetScopeContract(contract); + owners.Push(ancestor); + ownerContext = ancestor; + scopeDepth++; + } + owners.Pop(); + } + JsonElement element; - SourceRecoveryContext? previous = t_serializingSourceMember; + SourceMemberSerializationState? previous = t_serializingSourceMember; try { // Serialization is synchronous; restore the slot for nested invocations. - t_serializingSourceMember = member; + t_serializingSourceMember = new(member.Value, owners); // Value-type serialization must not track the original box as a reference cycle. - object? owner = originalContract.Type.IsValueType ? RuntimeHelpers.GetObjectValue(member.Owner) : member.Owner; - element = JsonSerializer.SerializeToElement(owner!, contract).GetProperty(property.Name); + element = JsonSerializer.SerializeToElement(ownerContext.GetSerializationOwner(), contract); + for (int i = 0; i < scopeDepth; i++) + { + element = element.GetProperty("value"); + } + element = element.GetProperty(property.Name); } finally { diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 6b8c987d2..7673472ec 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -95,10 +95,33 @@ UseStructuredContent = true, OutputSchema = preservedTool.ProtocolTool.OutputSchema, }); +McpServerTool ancestorTool = McpServerTool.Create( + new AotAncestorFunction(new(structuredOptions) + { + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }), + new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(""" + { + "type":"object", + "$defs":{"node":{"required":["missing"],"properties":{"missing":{ + "required":["owner"],"properties":{"owner":{"type":"null"}} + }}}}, + "properties":{ + "child":{"$ref":"#/$defs/node"}, + "items":{"items":{"$ref":"#/$defs/node"}}, + "entries":{"additionalProperties":{"$ref":"#/$defs/node"}} + } + } + """).RootElement, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -184,6 +207,18 @@ } } +var ancestorResult = await client.CallToolAsync("AncestorCycles"); +JsonElement ancestor = ancestorResult.StructuredContent!.Value; +foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() + .Concat(ancestor.GetProperty("entries").EnumerateObject().Select(property => property.Value)) + .Prepend(ancestor.GetProperty("child"))) +{ + if (node.GetProperty("missing").GetProperty("owner").ValueKind != JsonValueKind.Null) + { + throw new Exception($"Unexpected ancestor cycle output: {ancestor}"); + } +} + // List, get, and verify a skill. var skills = await client.ListSkillsAsync(); if (skills.Count != 1 || skills[0].Name != "aot") @@ -410,6 +445,52 @@ internal sealed class AotPreservedFunction(JsonSerializerOptions options, bool n new(_value); } +internal sealed class AotAncestorRoot +{ + public AotAncestorOwner Child { get; set; } = new(); + public AotAncestorOwner[] Items { get; set; } = [new(), new()]; + public Dictionary Entries { get; set; } = new() + { + ["root"] = new(), + ["container"] = new(), + }; +} + +internal sealed class AotAncestorOwner +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotAncestorValue Missing { get; set; } +} + +internal struct AotAncestorValue : IEquatable +{ + public object? Owner { get; set; } + public bool Equals(AotAncestorValue other) => true; + public override bool Equals(object? obj) => obj is AotAncestorValue; + public override int GetHashCode() => 0; +} + +internal sealed class AotAncestorFunction(JsonSerializerOptions options) : AIFunction +{ + private readonly AotAncestorRoot _value = CreateValue(); + public override string Name => "AncestorCycles"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(_value); + + private static AotAncestorRoot CreateValue() + { + AotAncestorRoot root = new(); + root.Child.Missing = new() { Owner = root }; + root.Items[0].Missing = new() { Owner = root }; + root.Items[1].Missing = new() { Owner = root.Items }; + root.Entries["root"].Missing = new() { Owner = root }; + root.Entries["container"].Missing = new() { Owner = root.Entries }; + return root; + } +} + internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver { private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new(); @@ -422,6 +503,7 @@ internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTyp [JsonSerializable(typeof(AotDerivedOutput))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Hashtable))] +[JsonSerializable(typeof(AotAncestorRoot))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs index ffbd4461b..ffa9e4846 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -81,11 +81,14 @@ public async Task StructuredOutput_RecoveryBoundary_DoesNotRebuildWideContracts( Assert.Equal(resolutionsBeforeRecovery, ownerResolutions); } - [Fact] - public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate(bool nested) { using Barrier barrier = new(2); JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = nested ? ReferenceHandler.IgnoreCycles : null; options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(new DefaultJsonTypeInfoResolver()); options.Converters.Add(new RecoveryBoundaryInvocationConverter(barrier, TestContext.Current.CancellationToken)); McpServerTool[] tools = [CreateTool(1), CreateTool(2)]; @@ -93,11 +96,17 @@ public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSep JsonElement[] results = await Task.WhenAll(tools.Select(tool => Task.Run(() => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken))); - Assert.Equal(1, results[0].GetProperty("value").GetInt32()); - Assert.Equal(2, results[1].GetProperty("value").GetInt32()); + Assert.Equal(1, (nested ? results[0].GetProperty("child") : results[0]).GetProperty("value").GetInt32()); + Assert.Equal(2, (nested ? results[1].GetProperty("child") : results[1]).GetProperty("value").GetInt32()); - McpServerTool CreateTool(int value) => CreateStructuredOutputTestTool( - new RecoveryBoundaryInvocationOutput { Value = new(value) }, """{"required":["value"]}""", options); + McpServerTool CreateTool(int value) + { + RecoveryBoundaryInvocationOutput output = new() { Value = new(value) }; + return nested + ? CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = output }, + """{"properties":{"child":{"required":["value"]}}}""", options) + : CreateStructuredOutputTestTool(output, """{"required":["value"]}""", options); + } } [Theory] @@ -137,6 +146,156 @@ public async Task StructuredOutput_RecoveryBoundary_PreservesOwnerCycleScope(boo } } + public static IEnumerable AncestorCycleCases() + { + foreach (string shape in new[] { "object", "nestedObject", "struct", "array", "arrayContainer", "dictionary", "dictionaryContainer", "rootArray", "rootDictionary", "extension" }) + { + foreach (bool strict in new[] { false, true }) + { + foreach (bool sourceGenerated in new[] { false, true }) + { + yield return [shape, strict, sourceGenerated]; + } + } + } + } + + [Theory] + [MemberData(nameof(AncestorCycleCases))] + public async Task StructuredOutput_RecoveryBoundary_PreservesAncestorCycleScope(string shape, bool strict, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryAncestorRoot root = new(); + RecoveryBoundaryCyclicOwner first = new(), second = new(); + RecoveryBoundaryCyclicOwner[] array = [first, second]; + Dictionary dictionary = new() + { + ["first/~"] = first, + ["second"] = second, + }; + object output = root; + object cycleTarget = root; + root.Child = shape switch + { + "nestedObject" => new RecoveryBoundaryAncestorRoot { Child = first }, + "struct" => new RecoveryBoundaryStructAncestor { Child = first }, + "array" or "arrayContainer" => array, + "dictionary" or "dictionaryContainer" => dictionary, + "extension" => new RecoveryBoundaryAncestorExtension { Data = new() { ["entry/~"] = first } }, + _ => first, + }; + if (shape is "arrayContainer" or "dictionaryContainer") + { + cycleTarget = root.Child!; + } + else if (shape is "rootArray" or "rootDictionary") + { + output = cycleTarget = shape == "rootArray" ? array : dictionary; + } + first.Missing = second.Missing = new() { Owner = cycleTarget }; + string ownerSchema = strict ? """{"type":"null"}""" : "{}"; + string itemSchema = $$$$""" + {"type":"object","required":["missing"],"properties":{"missing":{ + "type":"object","required":["owner"],"properties":{"owner":{{{{ownerSchema}}}}} + }}} + """; + string schema = (shape switch + { + "nestedObject" or "struct" => """{"properties":{"child":{"properties":{"child":ITEM}}}}""", + "array" or "arrayContainer" => """{"properties":{"child":{"items":ITEM}}}""", + "dictionary" or "dictionaryContainer" => """{"properties":{"child":{"additionalProperties":ITEM}}}""", + "rootArray" => """{"items":ITEM}""", + "rootDictionary" => """{"additionalProperties":ITEM}""", + "extension" => """{"properties":{"child":{"properties":{"entry/~":ITEM}}}}""", + _ => """{"properties":{"child":ITEM}}""", + }).Replace("ITEM", itemSchema); + McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement child = shape switch + { + "nestedObject" or "struct" => result.GetProperty("child").GetProperty("child"), + "rootArray" or "rootDictionary" => result, + "extension" => result.GetProperty("child").GetProperty("entry/~"), + _ => result.GetProperty("child"), + }; + if (shape is "array" or "arrayContainer" or "rootArray") + { + Assert.All(child.EnumerateArray(), AssertNullOwner); + } + else if (shape is "dictionary" or "dictionaryContainer" or "rootDictionary") + { + Assert.All(child.EnumerateObject(), property => AssertNullOwner(property.Value)); + } + else + { + AssertNullOwner(child); + } + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + if (ReferenceEquals(root, output)) + { + Assert.Equal(2, root.SerializingCalls); + Assert.Equal(2, root.GetterCalls); + } + + static void AssertNullOwner(JsonElement item) => + Assert.Equal(JsonValueKind.Null, item.GetProperty("missing").GetProperty("owner").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTreatNonAncestorAsCycle(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryCyclicOwner child = new() + { + Missing = new() { Owner = new RecoveryBoundaryNullableOutput { Note = "unrelated" } }, + }; + McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = child }, """ + {"properties":{"child":{"required":["missing"],"properties":{"missing":{ + "required":["owner"],"properties":{"owner":{ + "type":"object","required":["note"],"properties":{"note":{"const":"unrelated"}} + }} + }}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("unrelated", result.GetProperty("child").GetProperty("missing").GetProperty("owner").GetProperty("note").GetString()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTrackValueTypeAncestorBoxes(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryCyclicOwner child = new(); + object boxedAncestor = new RecoveryBoundaryStructAncestor { Child = child }; + child.Missing = new() { Owner = boxedAncestor }; + McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = boxedAncestor }, """ + {"properties":{"child":{"properties":{"child":{"required":["missing"]}}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement owner = result.GetProperty("child").GetProperty("child").GetProperty("missing").GetProperty("owner"); + Assert.Equal(JsonValueKind.Object, owner.ValueKind); + Assert.Equal(JsonValueKind.Null, owner.GetProperty("child").ValueKind); + } + [Theory] [InlineData(false, false, false)] [InlineData(false, true, false)] @@ -577,6 +736,28 @@ private sealed class RecoveryBoundaryCyclicOwner public RecoveryBoundaryCyclicValue Missing { get; set; } } + private sealed class RecoveryBoundaryAncestorRoot : IJsonOnSerializing + { + public object? Child { get; set; } + public int Existing { get { GetterCalls++; return 5; } } + [JsonIgnore] + public int GetterCalls { get; private set; } + [JsonIgnore] + public int SerializingCalls { get; private set; } + public void OnSerializing() => SerializingCalls++; + } + + private sealed class RecoveryBoundaryAncestorExtension + { + [JsonExtensionData] + public Dictionary Data { get; set; } = new(); + } + + private struct RecoveryBoundaryStructAncestor + { + public RecoveryBoundaryCyclicOwner Child { get; set; } + } + private struct RecoveryBoundaryCyclicStructOwner { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] @@ -643,6 +824,11 @@ private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOption [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))] [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))] + [JsonSerializable(typeof(RecoveryBoundaryAncestorRoot))] + [JsonSerializable(typeof(RecoveryBoundaryAncestorExtension))] + [JsonSerializable(typeof(RecoveryBoundaryStructAncestor))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner[]))] + [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(object))] private partial class RecoveryBoundaryJsonContext : JsonSerializerContext; } From d0fe5e4e7f846d095d9e1fac650dc68d5c4a2a5a Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 28 Oct 2026 15:57:06 -0700 Subject: [PATCH 18/30] Respect effective schemas and serialization boundaries during output recovery --- .../Server/AIFunctionMcpServerTool.cs | 431 ++++++++++++++++-- .../Program.cs | 19 +- .../Server/DelegatingToolOutputSchemaTests.cs | 34 ++ .../McpServerToolRecoveryBoundaryTests.cs | 84 ++++ .../Server/McpServerToolRepairSearchTests.cs | 78 ++++ 5 files changed, 606 insertions(+), 40 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f5b863fd3..3ed13df40 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -329,7 +329,10 @@ public override async ValueTask InvokeAsync( return callToolResult; } - JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion); + JsonElement? recoverySchema = request.MatchedPrimitive is McpServerTool matchedTool + ? matchedTool.ProtocolTool.OutputSchema + : ProtocolTool.OutputSchema; + JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion, recoverySchema); return result switch { AIContent aiContent => new() @@ -772,7 +775,8 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string } } - private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion) + private JsonElement? CreateStructuredResponse( + object? aiFunctionResult, string? negotiatedProtocolVersion, JsonElement? recoverySchema) { if (ProtocolTool.OutputSchema is null) { @@ -799,7 +803,7 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string break; } - elementResult = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult); + elementResult = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult, recoverySchema); // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided @@ -821,10 +825,11 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string private JsonElement? AddMissingRequiredNullableProperties( JsonElement? elementResult, - object? originalResult) + object? originalResult, + JsonElement? recoverySchema) { if (elementResult is not { } result || - ProtocolTool.OutputSchema is not { } outputSchema || + recoverySchema is not { } outputSchema || result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { return elementResult; @@ -888,11 +893,109 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) Queue fallback = new(); HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; schemaDocument ??= schemaNode; - references ??= new(schemaDocument); + references ??= new(schemaDocument, schema.BaseUri); traversalBudget ??= new(); // Cache predicates independently of their callers. Conditional expectations and // instance paths remain on each group and are checked on every tentative repair. Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = new(); + Dictionary conjunctiveSchemas = new(StringComparer.Ordinal); + List unevaluatedGroups = []; + Dictionary unevaluatedCandidates = new(StringComparer.Ordinal); + SchemaReferenceResolver? evaluationReferences = null; + + HashSet<(string Path, Uri Schema)> GetUnevaluatedApplications(JsonNode instance) + { + HashSet<(string, Uri)> applications = []; + try + { + evaluationReferences ??= new( + JsonSerializer.SerializeToNode(schema.Root.Source, McpJsonUtilities.JsonContext.Default.JsonElement)!, + schema.BaseUri); + JsonElement element = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + Stack results = new(); + results.Push(schema.Evaluate(element, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical })); + while (results.Count > 0 && traversalBudget.TrySpend(1)) + { + EvaluationResults evaluation = results.Pop(); + string path = evaluation.EvaluationPath.ToString(); + // Evaluation paths retain caller provenance even when SchemaLocation is imprecise across references. + if ((path.EndsWith("/unevaluatedProperties", StringComparison.Ordinal) || + path.EndsWith("/unevaluatedItems", StringComparison.Ordinal)) && + evaluationReferences.TryResolveEvaluationPath(path, out Uri? application)) + { + applications.Add((evaluation.InstanceLocation.ToString(), application!)); + } + + if (evaluation.Details is { } details) + { + foreach (EvaluationResults detail in details) + { + results.Push(detail); + } + } + } + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + applications.Clear(); + } + + return applications; + } + + JsonNode FinalizeRepair(JsonNode instance) + { + if (unevaluatedGroups.Count == 0) + { + return instance; + } + + HashSet<(string Path, Uri Schema)> applications = GetUnevaluatedApplications(instance); + HashSet checkedKeys = new(StringComparer.Ordinal); + foreach (RepairCandidateGroup group in unevaluatedGroups) + { + if (group.Unevaluated is not { } origin || + applications.Contains((origin.InstancePath, references.GetSchemaUri(origin.Schema)))) + { + continue; + } + + foreach (string key in group.Keys) + { + if (!checkedKeys.Add(key)) + { + continue; + } + if (checkedKeys.Count > maxCandidateSetEvaluations || !traversalBudget.TrySpend(1)) + { + return instance; + } + + if (!unevaluatedCandidates.TryGetValue(key, out RepairCandidate? candidate)) + { + continue; + } + + string parentPath = candidate.ParentPath; + string name = candidate.PropertyName; + if ((TryGetNodeAtPath(resultNode, parentPath, out JsonNode? originalParent) && + originalParent is JsonObject originalObject && originalObject.ContainsKey(name)) || + !TryGetNodeAtPath(instance, parentPath, out JsonNode? parent) || + parent is not JsonObject objectParent || !objectParent.TryGetPropertyValue(name, out JsonNode? value)) + { + continue; + } + + objectParent.Remove(name); + if (!IsRepairValid(instance)) + { + objectParent[name] = value; + } + } + } + + return instance; + } JsonSchema? GetBranchSchema(JsonNode branch, List scope) { @@ -911,6 +1014,10 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) ["$ref"] = references.GetSchemaUri(branch).AbsoluteUri, ["$defs"] = new JsonObject { ["document"] = document }, }; + if (references.SupplementalSchemas.Contains(branch)) + { + branchDocument["$defs"]!["scope"] = branch.DeepClone(); + } TryBuildJsonSchema(branchDocument, out branchSchema); branchSchemas.Add(schemaKey, branchSchema); } @@ -953,9 +1060,14 @@ bool IsRepairValid(JsonNode instance) => Dictionary contexts = new(StringComparer.Ordinal); List groups = []; + HashSet<(string Path, Uri Schema)>? applications = null; CollectSchemaContexts( instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, - new SchemaTraversal(traversalBudget), groups, references, dynamicScope); + new SchemaTraversal(traversalBudget, (subschema, path) => + (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema))), + (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsSchemaValid(compiled, value)), + groups, references, dynamicScope); + unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null)); if (inheritedConditions is { Count: > 0 }) { groups = groups.Select(group => group with @@ -964,8 +1076,68 @@ bool IsRepairValid(JsonNode instance) => }).ToList(); } + // Conjunctive child schemas must qualify together, not as independent alternatives. + var envelopes = groups.Where(static group => group.EnclosesInstance) + .GroupBy(group => (group.InstancePath, Scope: references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty)) + .ToDictionary(static bucket => bucket.Key, static bucket => bucket.ToList()); + HashSet combinedEnvelopes = []; + List qualifiedGroups = []; + foreach (RepairCandidateGroup group in groups) + { + if (combinedEnvelopes.Contains(group)) + { + continue; + } + + if (!group.EnclosesInstance) + { + qualifiedGroups.Add(group); + continue; + } + + var key = (group.InstancePath, references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty); + List conjuncts = envelopes[key].Where(other => + other.Choices.SequenceEqual(group.Choices) && other.Conditions.SequenceEqual(group.Conditions) && + (other.Alternatives ?? []).SequenceEqual(group.Alternatives ?? [])).ToList(); + if (conjuncts.Count == 1) + { + qualifiedGroups.Add(group); + continue; + } + + string schemaKey = string.Join("\n", conjuncts.Select(item => references.GetSchemaUri(item.Schema).AbsoluteUri) + .Distinct(StringComparer.Ordinal).OrderBy(static uri => uri, StringComparer.Ordinal)); + if (!conjunctiveSchemas.TryGetValue(schemaKey, out JsonNode? conjunction)) + { + JsonArray clauses = []; + foreach (string uri in schemaKey.Split('\n')) + { + clauses.Add((JsonNode)new JsonObject { ["$ref"] = uri }); + } + conjunction = new JsonObject { ["$id"] = "https://mcp.invalid/recovery/" + Guid.NewGuid().ToString("N"), ["allOf"] = clauses }; + references.AddSupplementalSchema(conjunction); + conjunctiveSchemas.Add(schemaKey, conjunction); + } + + qualifiedGroups.Add(group with + { + Schema = conjunction, + Keys = new(conjuncts.SelectMany(static item => item.Keys), StringComparer.Ordinal), + DirectKeys = new(conjuncts.SelectMany(static item => item.DirectKeys), StringComparer.Ordinal), + }); + combinedEnvelopes.UnionWith(conjuncts); + } + groups = qualifiedGroups; + List candidates = CreateRepairCandidates( instance, originalResult, serializerOptions, contexts); + if (unevaluatedGroups.Count > 0) + { + foreach (RepairCandidate candidate in candidates) + { + unevaluatedCandidates[candidate.Key] = candidate; + } + } if (candidates.Count == 0) { return null; @@ -977,6 +1149,7 @@ bool IsRepairValid(JsonNode instance) => Dictionary<(JsonNode Applicator, string Path), JsonNode> exclusiveChoices = new(); JsonNode combinedResult = instance.DeepClone(); bool hasCombinedRepairs = false; + HashSet completedInstances = new(StringComparer.Ordinal); foreach (RepairCandidate candidate in ungrouped) { ApplyRepairCandidate(combinedResult, candidate); @@ -985,6 +1158,16 @@ bool IsRepairValid(JsonNode instance) => foreach (RepairCandidateGroup group in groups) { + string enclosingPath = group.InstancePath; + while (enclosingPath.Length > 0 && !completedInstances.Contains(enclosingPath)) + { + enclosingPath = enclosingPath.Substring(0, enclosingPath.LastIndexOf('/')); + } + if (enclosingPath.Length > 0) + { + continue; + } + if (group.Choices.Any(choice => exclusiveChoices.TryGetValue((choice.Applicator, choice.InstancePath), out JsonNode? selected) && !ReferenceEquals(selected, choice.Branch))) @@ -1032,7 +1215,10 @@ bool IsRepairValid(JsonNode instance) => } bool branchIsValid = IsSchemaValid(branchSchema, branchResult); - if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth) + if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth && + !(group.InstancePath.Length == 0 && ReferenceEquals(group.Schema, schemaNode) && + references.GetScopeKey(group.DynamicScope) == + references.GetScopeKey(dynamicScope ?? [references.GetResourceRoot(schemaNode)]))) { SourceRecoveryContext? branchSource = null; if (originalResult is not null) @@ -1107,6 +1293,10 @@ ancestorInstance is not null && } hasCombinedRepairs = true; + if (group.EnclosesInstance) + { + completedInstances.Add(group.InstancePath); + } foreach (RepairExclusiveChoice choice in group.Choices) { exclusiveChoices[(choice.Applicator, choice.InstancePath)] = choice.Branch; @@ -1171,7 +1361,7 @@ ancestorInstance is not null && if (EnqueueRepairs(resultNode, 0) is JsonNode composedResult) { - return composedResult; + return FinalizeRepair(composedResult); } for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) { @@ -1190,12 +1380,12 @@ ancestorInstance is not null && evaluations++; if (IsRepairValid(candidateResult)) { - return candidateResult; + return FinalizeRepair(candidateResult); } if (EnqueueRepairs(candidateResult, attempt.Depth) is JsonNode repairedResult) { - return repairedResult; + return FinalizeRepair(repairedResult); } } @@ -1239,7 +1429,7 @@ private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) private sealed record OutputSchemaCache(JsonElement Source, JsonNode? Node, JsonSchema? Schema); - private static bool IsSchemaValid(JsonSchema schema, JsonNode instance) + private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance) { try { @@ -1363,7 +1553,11 @@ private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode sch private sealed record RepairCandidateGroup( HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema, - List DynamicScope, List Choices, List Conditions); + List DynamicScope, List Choices, List Conditions, + RepairUnevaluatedApplication? Unevaluated = null, bool EnclosesInstance = false, + List? Alternatives = null); + + private sealed record RepairUnevaluatedApplication(JsonNode Schema, string InstancePath); private static void ReplaceRepairBranch(JsonNode currentBranch, string instancePath, JsonNode replacement) { @@ -1398,9 +1592,13 @@ internal bool TrySpend(int work) } } - private sealed class SchemaTraversal(RepairTraversalBudget budget) + private sealed class SchemaTraversal( + RepairTraversalBudget budget, Func appliesUnevaluated, + Func, bool> isBranchValid) { internal RepairTraversalBudget Budget { get; } = budget; + internal Func AppliesUnevaluated { get; } = appliesUnevaluated; + internal Func, bool> IsBranchValid { get; } = isBranchValid; internal Dictionary<(JsonNode Schema, string Path, string Scope), SchemaTraversalResult> Completed { get; } = new(); internal HashSet<(JsonNode Schema, string Path, string Scope)> Active { get; } = []; internal int IncompleteVersion { get; set; } @@ -1538,14 +1736,14 @@ private static void CollectSchemaContexts( continue; } - CollectSchemaContexts( + CollectSchemaBranch( propertyInstance, property.Value, schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, traversal, - groups, references, dynamicScope); + groups, references, dynamicScope, enclosesInstance: true); } } @@ -1566,35 +1764,37 @@ private static void CollectSchemaContexts( if (patternProperty.Value is not null && Regex.IsMatch(property.Key, patternProperty.Key)) { matchedPattern = true; - CollectSchemaContexts( + CollectSchemaBranch( property.Value, patternProperty.Value, schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, traversal, - groups, references, dynamicScope); + groups, references, dynamicScope, enclosesInstance: true); } } } if (!isExplicitProperty && !matchedPattern && additionalProperties is not null) { - CollectSchemaContexts( + CollectSchemaBranch( property.Value, additionalProperties, schemaRoot, AppendJsonPointer(instancePath, property.Key), contexts, traversal, - groups, references, dynamicScope); + groups, references, dynamicScope, enclosesInstance: true); } - if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema) + if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema && + traversal.AppliesUnevaluated(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key))) { CollectSchemaBranch( property.Value, unevaluatedPropertySchema, schemaRoot, - AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope); + AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope, + unevaluated: new(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key))); } } } @@ -1603,29 +1803,38 @@ private static void CollectSchemaContexts( { JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; JsonNode? items = schemaObject["items"]; + double minimumMatches = schemaObject["minContains"] is JsonValue minimum && + minimum.TryGetValue(out double count) ? count : 1; + bool repairContains = schemaObject["contains"] is JsonNode contains && + arrayInstance.Count(item => traversal.IsBranchValid(contains, item, dynamicScope)) < minimumMatches; for (int i = 0; i < arrayInstance.Count; i++) { JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; if (itemSchema is not null) { - CollectSchemaContexts( + CollectSchemaBranch( arrayInstance[i], itemSchema, schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), contexts, traversal, - groups, references, dynamicScope); + groups, references, dynamicScope, enclosesInstance: true); } foreach (string keyword in new[] { "contains", "unevaluatedItems" }) { - if (schemaObject[keyword] is JsonNode subschema) + if (schemaObject[keyword] is JsonNode subschema && + (keyword != "contains" || repairContains) && + (keyword != "unevaluatedItems" || + traversal.AppliesUnevaluated(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture))))) { CollectSchemaBranch( arrayInstance[i], subschema, schemaRoot, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), - contexts, traversal, groups, references, dynamicScope); + contexts, traversal, groups, references, dynamicScope, + unevaluated: keyword == "unevaluatedItems" + ? new(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture))) : null); } } } @@ -1675,6 +1884,17 @@ private static void CollectSchemaContexts( continue; } + if (compositionKeyword != "allOf") + { + int validBranches = composition.Count(branch => branch is not null && + traversal.IsBranchValid(branch, instanceNode, dynamicScope)); + if ((compositionKeyword == "anyOf" && validBranches > 0) || + (compositionKeyword == "oneOf" && validBranches == 1)) + { + continue; + } + } + foreach (JsonNode? branch in composition) { if (branch is not null) @@ -1688,7 +1908,8 @@ private static void CollectSchemaContexts( else { CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, - compositionKeyword == "oneOf" ? composition : null); + compositionKeyword == "oneOf" ? composition : null, + alternativeApplicator: compositionKeyword == "anyOf" ? composition : null); } } } @@ -1747,7 +1968,10 @@ private static void CollectSchemaBranch( SchemaReferenceResolver references, List dynamicScope, JsonNode? exclusiveApplicator = null, - RepairCondition? condition = null) + RepairCondition? condition = null, + RepairUnevaluatedApplication? unevaluated = null, + bool enclosesInstance = false, + JsonNode? alternativeApplicator = null) { Dictionary branchContexts = new(StringComparer.Ordinal); List branchGroups = []; @@ -1758,6 +1982,8 @@ private static void CollectSchemaBranch( List choices = exclusiveApplicator is null ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)]; List conditions = condition is null ? [] : [condition]; + List alternatives = alternativeApplicator is null + ? [] : [new(alternativeApplicator, branch, instancePath, branchScope)]; HashSet group = new(StringComparer.Ordinal); foreach (KeyValuePair entry in branchContexts) { @@ -1774,7 +2000,7 @@ private static void CollectSchemaBranch( directKeys.ExceptWith(branchGroups.SelectMany(static nested => nested.Keys)); // Restore this branch's own discriminator before composing nested selectors. // Nested alternatives must not be added en masse before re-evaluating them. - groups.Add(new(group, directKeys, instancePath, branch, branchScope, choices, conditions)); + groups.Add(new(group, directKeys, instancePath, branch, branchScope, choices, conditions, unevaluated, enclosesInstance, alternatives)); } foreach (RepairCandidateGroup branchGroup in branchGroups) @@ -1783,6 +2009,8 @@ private static void CollectSchemaBranch( { Choices = [.. choices, .. branchGroup.Choices], Conditions = [.. conditions, .. branchGroup.Conditions], + Unevaluated = unevaluated ?? branchGroup.Unevaluated, + Alternatives = [.. alternatives, .. branchGroup.Alternatives ?? []], }); } } @@ -1993,6 +2221,8 @@ private sealed class SourceRecoveryContext( private JsonTypeInfo? _activeContract; private List? _items; private bool _dictionaryRead; + private bool _extensionDataRead; + private object? _extensionData; internal object? Value { get; } = value; internal JsonTypeInfo Contract { get; } = contract; @@ -2132,20 +2362,40 @@ private sealed class SourceRecoveryContext( return null; } + if (serializedParent is JsonObject serializedObject && serializedObject.ContainsKey(name) && + sourceContract.ExtensionData is { } extension && + GetExtensionData(extension) is { } collisionData && + (extension.CustomConverter is not null || collisionData switch + { + IDictionary data => data.ContainsKey(name), + IDictionary data => data.ContainsKey(name), + JsonObject data => data.ContainsKey(name), + _ => true, + })) + { + // The JSON path may belong to extension data instead of this CLR member. + return null; + } + object? memberValue = member.Get(Value); + if (serializedParent is JsonObject parentObject && parentObject.ContainsKey(name) && + member.ShouldSerialize?.Invoke(Value, memberValue) == false) + { + return null; + } + JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); return new(memberValue, memberContract, member, Value, typeInfo, this); } JsonPropertyInfo? extensionData = sourceContract.ExtensionData; - if (extensionData?.Get is not { } getExtensionData || - extensionData.CustomConverter is not null || IsExcludedReadOnlyMember(extensionData, options)) + if (extensionData is null || extensionData.CustomConverter is not null) { return null; } - object? extensionValue = getExtensionData(Value); - if (extensionValue is null || extensionData.ShouldSerialize?.Invoke(Value, extensionValue) == false) + object? extensionValue = GetExtensionData(extensionData); + if (extensionValue is null) { return null; } @@ -2170,6 +2420,24 @@ private sealed class SourceRecoveryContext( return null; } + private object? GetExtensionData(JsonPropertyInfo property) + { + if (!_extensionDataRead) + { + _extensionDataRead = true; + if (property.Get is { } getter && !IsExcludedReadOnlyMember(property, Contract.Options)) + { + object? data = getter(Value!); + if (property.ShouldSerialize?.Invoke(Value!, data) != false) + { + _extensionData = data; + } + } + } + + return _extensionData; + } + internal object GetSerializationOwner() => GetActiveContract().Type.IsValueType ? RuntimeHelpers.GetObjectValue(Value!)! : Value!; @@ -2544,13 +2812,93 @@ private sealed class SchemaReferenceResolver { private readonly Dictionary _resources = new(); private readonly Dictionary _resourceIds = new(); - private readonly List _schemas = []; + private readonly List _dynamicSchemas = []; + private readonly JsonNode _root; + private readonly Uri? _rootUri; + internal HashSet SupplementalSchemas { get; } = []; + internal bool HasDynamicReferences => _dynamicSchemas.Count != 0; - internal SchemaReferenceResolver(JsonNode schema) + internal SchemaReferenceResolver(JsonNode schema, Uri? rootUri = null) { + _root = schema; + _rootUri = rootUri; Register(schema, new Uri("https://mcp.invalid/schema"), isRoot: true); } + internal void AddSupplementalSchema(JsonNode schema) + { + Register(schema, new Uri("https://mcp.invalid/recovery")); + SupplementalSchemas.Add(schema); + } + + internal bool TryResolveEvaluationPath(string path, out Uri? schemaUri) + { + schemaUri = null; + JsonNode? node = _root; + List scope = [GetResourceRoot(_root)]; + foreach (string segment in path.Split('/').Skip(1).Select(DecodeJsonPointerSegment)) + { + if (node is JsonObject obj) + { + if (segment is "$ref" or "$dynamicRef" && + obj[segment] is JsonValue value && value.TryGetValue(out string? reference) && + reference is not null) + { + if (!TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out JsonNode resource)) + { + return false; + } + + if (segment == "$dynamicRef" && target is JsonObject targetObject && + targetObject["$dynamicAnchor"]?.GetValue() is string anchor && + reference.IndexOf('#') is int fragment && fragment >= 0 && + Uri.UnescapeDataString(reference.Substring(fragment + 1)) == anchor) + { + foreach (JsonNode caller in scope) + { + if (TryFindSchemaAnchor(caller, anchor, out JsonNode? bound, dynamicOnly: true)) + { + target = bound; + resource = caller; + break; + } + } + } + + node = target; + scope = EnterResource(scope, resource); + } + else + { + node = obj[segment]; + } + } + else if (node is JsonArray array && + int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < array.Count) + { + node = array[index]; + } + else + { + return false; + } + + if (node is null) + { + return false; + } + + if (_resourceIds.ContainsKey(node)) + { + scope = EnterResource(scope, node); + } + } + + schemaUri = GetSchemaUri(node!); + return true; + } + internal Uri GetSchemaUri(JsonNode schema) { JsonNode resource = GetResourceRoot(schema); @@ -2580,7 +2928,7 @@ internal void BindDynamicReferences(JsonNode document, JsonNode copy, List(out string? id) && + if (isRoot && _rootUri is not null) + { + baseUri = _rootUri; + } + else if (schemaObject["$id"] is JsonValue idValue && idValue.TryGetValue(out string? id) && Uri.TryCreate(baseUri, id, out Uri? resourceUri)) { baseUri = resourceUri; diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 7673472ec..390704112 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -63,11 +63,12 @@ "extension":{"properties":{"child/~":{"required":["note"]}}}, "contract":{"oneOf":[{"required":["flag"]},{"required":["note"]}]}, "nullable":{"required":["note"]}, + "unevaluated":{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}, "delegatingCount":{"const":0}, - "unions":{"type":"array","items":{"oneOf":[ + "unions":{"type":"array","allOf":[{"items":{"oneOf":[ {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} - ]}} + ]}},{"items":{"not":{"required":["child1"]}}}]} } } """).RootElement, @@ -172,6 +173,9 @@ structured.GetProperty("contract").GetProperty("note").ValueKind != JsonValueKind.Null || structured.GetProperty("contract").TryGetProperty("flag", out _) || structured.GetProperty("nullable").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("unevaluated").GetProperty("evaluated\0path").GetProperty("real").ValueKind != JsonValueKind.Null || + structured.GetProperty("unevaluated").GetProperty("evaluated\0path").TryGetProperty("note", out _) || + structured.GetProperty("unevaluated").GetProperty("other").GetProperty("note").ValueKind != JsonValueKind.Null || structured.GetProperty("delegatingCount").GetInt32() != 0 || structured.TryGetProperty("count", out _)) { @@ -295,6 +299,7 @@ internal sealed class AotStructuredOutput public AotExtensionOutput Extension { get; init; } = new(); public AotBaseOutput Contract { get; init; } = new AotDerivedOutput(); public AotNullableChild? Nullable { get; init; } = new AotNullableChild(); + public AotUnevaluatedOutput Unevaluated { get; init; } = new(); [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonConverter(typeof(AotDelegatingIntConverter))] @@ -359,6 +364,16 @@ internal struct AotNullableChild { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Real { get; init; } +} + +internal sealed class AotUnevaluatedOutput +{ + [JsonPropertyName("evaluated\0path")] + public AotNullableChild Evaluated { get; init; } + public AotNullableChild Other { get; init; } } internal sealed class AotGenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index 1b8857e93..a654e9b0d 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -14,6 +14,40 @@ namespace ModelContextProtocol.Tests.Server; public partial class DelegatingToolOutputSchemaTests { + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema(string protocolVersion, bool outerRequiresNull) + { + JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optionalSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "effective_note", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new DelegatingTool(inner), outerRequiresNull ? requiredSchema : optionalSchema); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + Assert.Equal(outerRequiresNull, content.TryGetProperty("note", out _)); + }); + } + [Theory] [InlineData("2025-06-18", 1, false)] [InlineData("2025-06-18", 2, false)] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs index ffa9e4846..33edee52a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -11,6 +11,59 @@ namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_PrunesUnevaluatedRepairsAtControlCharacterPaths(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + McpServerTool tool = CreateRecoveryBoundaryTool( + new Dictionary + { + ["evaluated\0path"] = new(), + ["other"] = new(), + }, """{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("evaluated\0path").GetProperty("real").ValueKind); + Assert.False(result.GetProperty("evaluated\0path").TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, result.GetProperty("other").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTraverseHiddenExtensionDataCollisions(bool sourceGenerated, bool defaultIgnored) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(info => + { + if (info.Type == typeof(RecoveryBoundaryFilteredParent) && !defaultIgnored) + { + info.Properties.Single(property => property.Name == "child").ShouldSerialize = static (_, _) => false; + } + }); + object output = defaultIgnored + ? new RecoveryBoundaryDefaultParent { Child = new() { Count = 42 } } + : new RecoveryBoundaryFilteredParent(); + McpServerTool tool = CreateRecoveryBoundaryTool(output, """ + {"properties":{"child":{"required":["count"],"properties":{"count":{"type":"integer"}}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Empty(result.GetProperty("child").EnumerateObject()); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] @@ -592,6 +645,34 @@ private sealed class RecoveryBoundaryConvertedOutput public int Count { get; set; } } + private sealed class RecoveryBoundaryControlNameOutput + { + public string? Real { get; set; } + public string? Note { get; set; } + } + + private sealed class RecoveryBoundaryFilteredParent + { + public RecoveryBoundaryConvertedOutput Child { get; set; } = new() { Count = 42 }; + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() }; + } + + private sealed class RecoveryBoundaryDefaultParent + { + public RecoveryBoundaryHiddenValue Child { get; set; } + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() }; + } + + private struct RecoveryBoundaryHiddenValue : IEquatable + { + public int Count { get; set; } + public bool Equals(RecoveryBoundaryHiddenValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryHiddenValue; + public override int GetHashCode() => 0; + } + private sealed class RecoveryBoundaryDelegatingConverter : JsonConverter { private bool _writing; @@ -822,6 +903,9 @@ private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOption [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))] [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))] [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(RecoveryBoundaryFilteredParent))] + [JsonSerializable(typeof(RecoveryBoundaryDefaultParent))] [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))] [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))] [JsonSerializable(typeof(RecoveryBoundaryAncestorRoot))] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs index 29a532486..2ab4d790f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -8,6 +8,84 @@ namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { + [Theory] + [InlineData("anyOf")] + [InlineData("oneOf")] + [InlineData("allOf")] + [InlineData("reference")] + [InlineData("crossParents")] + [InlineData("crossReferences")] + public async Task StructuredOutput_Search_QualifiesEnclosingItemAssertions(string shape) + { + string item = shape switch + { + "oneOf" => """{"properties":{"a":true},"additionalProperties":false,"oneOf":[{"required":["b"]},{"required":["a"]}]}""", + "allOf" => """{"allOf":[{"anyOf":[{"required":["a"]},{"required":["b"]}]},{"not":{"required":["b"]}}]}""", + "reference" => """{"$ref":"#/$defs/choice","properties":{"a":true},"additionalProperties":false}""", + _ => """{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}""", + }; + var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null, C = (string?)null }).ToArray(); + string schema = shape switch + { + "crossParents" => """{"allOf":[{"items":{"anyOf":[{"required":["b"]},{"required":["a"]}]}},{"items":{"required":["c"],"not":{"required":["b"]}}}]}""", + "crossReferences" => """{"allOf":[{"items":{"$ref":"#/$defs/choice"}},{"items":{"$ref":"#/$defs/guard"}}],"$defs":{"choice":{"$id":"choice","anyOf":[{"required":["b"]},{"required":["a"]}]},"guard":{"$id":"guard","required":["c"],"not":{"required":["b"]}}}}""", + _ => $$$$"""{"items":{{{{item}}}},"$defs":{"choice":{"anyOf":[{"required":["a"]},{"required":["b"]}]}}}""", + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), child => + { + Assert.Equal(JsonValueKind.Null, child.GetProperty("a").ValueKind); + Assert.False(child.TryGetProperty("b", out _)); + Assert.Equal(shape is "crossParents" or "crossReferences", child.TryGetProperty("c", out _)); + }); + } + + [Theory] + [InlineData("object")] + [InlineData("array")] + [InlineData("reference")] + [InlineData("allOf")] + [InlineData("anyOf")] + [InlineData("contains")] + [InlineData("becomesEvaluated")] + [InlineData("validAlternative")] + public async Task StructuredOutput_Search_DoesNotRepairEvaluatedOptionalMembers(string shape) + { + var first = new { Note = (string?)null, Real = (string?)null }; + var second = new { Note = (string?)null, Real = (string?)null }; + bool array = shape is "array" or "contains"; + object output = array ? new[] { first, second } : new { Evaluated = first, Other = second }; + string schema = shape switch + { + "array" => """{"prefixItems":[{"type":"object"}],"unevaluatedItems":{"required":["note"]}}""", + "contains" => """{"prefixItems":[{"properties":{"real":true}}],"contains":{"required":["real"]},"unevaluatedItems":{"required":["note"]}}""", + "reference" => """{"$id":"https://example.test/unevaluated","$defs":{"rule":{"properties":{"evaluated":true},"unevaluatedProperties":{"required":["note"]}}},"allOf":[{"$ref":"#/$defs/rule"}]}""", + "allOf" => """{"allOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""", + "anyOf" => """{"anyOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""", + "becomesEvaluated" => """{"properties":{"evaluated":{"required":["real"]}},"unevaluatedProperties":{"required":["note"]}}""", + "validAlternative" => """{"anyOf":[{"properties":{"evaluated":true}},{"properties":{"other":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""", + _ => """{"properties":{"evaluated":{"type":"object"}},"unevaluatedProperties":{"required":["note"]}}""", + }; + if (shape == "contains") + { + output = new[] { new { Note = (string?)null, Real = (string?)"present" }, first }; + } + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement evaluated = array ? result[0] : result.GetProperty("evaluated"); + JsonElement other = array ? result[1] : result.GetProperty("other"); + Assert.False(evaluated.TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, other.GetProperty("note").ValueKind); + Assert.False(other.TryGetProperty("real", out _)); + } + [Fact] public async Task StructuredOutput_Search_ComposesIndependentExclusiveChoicesAtSamePath() { From f249b4ae4e2059848c9e216971f3c70348dc46c9 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 29 Oct 2026 15:24:41 +0800 Subject: [PATCH 19/30] Fix structured output recovery edge cases --- .../Server/AIFunctionMcpServerTool.cs | 77 ++++++++++--- .../Program.cs | 23 +++- .../Server/McpServerToolTests.cs | 108 ++++++++++++++++++ 3 files changed, 191 insertions(+), 17 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 3ed13df40..91417952c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -835,16 +835,16 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); JsonNode? schemaNode = cachedSchema.Node; JsonSchema? schema = cachedSchema.Schema; - if (resultNode is null || schemaNode is null || schema is null) + if (schemaNode is null || schema is null || IsSchemaValid(schema, result) || HasDuplicatePropertyNames(result)) { return elementResult; } - if (IsSchemaValid(schema, resultNode) || HasDuplicatePropertyNames(result)) + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + if (resultNode is null) { return elementResult; } @@ -854,7 +854,8 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) schemaNode, schema, originalResult is null ? null : new SourceRecoveryContext( - originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), + originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object)), + fallbackType: GetDeclaredReturnType(originalResult)), AIFunction.JsonSerializerOptions); return repairedResult is null @@ -862,6 +863,23 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) : JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); } + private Type? GetDeclaredReturnType(object result) + { + Type? returnType = AIFunction.UnderlyingMethod?.ReturnType; + if (returnType is { IsGenericType: true } && + (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))) + { + returnType = returnType.GetGenericArguments()[0]; + } + + if (returnType is null || returnType == typeof(object) || !returnType.IsInstanceOfType(result)) + { + return null; + } + + return returnType; + } + /// /// Restores omitted required properties only when the complete result validates against the /// published schema. The serializer can omit a value because of either the global ignore @@ -903,9 +921,9 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) Dictionary unevaluatedCandidates = new(StringComparer.Ordinal); SchemaReferenceResolver? evaluationReferences = null; - HashSet<(string Path, Uri Schema)> GetUnevaluatedApplications(JsonNode instance) + HashSet<(string Path, string Schema)> GetUnevaluatedApplications(JsonNode instance) { - HashSet<(string, Uri)> applications = []; + HashSet<(string, string)> applications = []; try { evaluationReferences ??= new( @@ -923,7 +941,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) path.EndsWith("/unevaluatedItems", StringComparison.Ordinal)) && evaluationReferences.TryResolveEvaluationPath(path, out Uri? application)) { - applications.Add((evaluation.InstanceLocation.ToString(), application!)); + applications.Add((evaluation.InstanceLocation.ToString(), application!.AbsoluteUri)); } if (evaluation.Details is { } details) @@ -950,12 +968,12 @@ JsonNode FinalizeRepair(JsonNode instance) return instance; } - HashSet<(string Path, Uri Schema)> applications = GetUnevaluatedApplications(instance); + HashSet<(string Path, string Schema)> applications = GetUnevaluatedApplications(instance); HashSet checkedKeys = new(StringComparer.Ordinal); foreach (RepairCandidateGroup group in unevaluatedGroups) { if (group.Unevaluated is not { } origin || - applications.Contains((origin.InstancePath, references.GetSchemaUri(origin.Schema)))) + applications.Contains((origin.InstancePath, references.GetSchemaUri(origin.Schema).AbsoluteUri))) { continue; } @@ -1060,11 +1078,11 @@ bool IsRepairValid(JsonNode instance) => Dictionary contexts = new(StringComparer.Ordinal); List groups = []; - HashSet<(string Path, Uri Schema)>? applications = null; + HashSet<(string Path, string Schema)>? applications = null; CollectSchemaContexts( instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, new SchemaTraversal(traversalBudget, (subschema, path) => - (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema))), + (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema).AbsoluteUri)), (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsSchemaValid(compiled, value)), groups, references, dynamicScope); unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null)); @@ -1442,6 +1460,18 @@ private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance) } } + private static bool IsSchemaValid(JsonSchema schema, JsonElement instance) + { + try + { + return schema.Evaluate(instance).IsValid; + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + return false; + } + } + private static List CreateRepairCandidates( JsonNode resultNode, SourceRecoveryContext? originalResult, @@ -1465,7 +1495,7 @@ private static List CreateRepairCandidates( continue; } - string candidateKey = contextEntry.Key + "\u0000" + propertyName; + string candidateKey = AppendJsonPointer(contextEntry.Key, propertyName); if (!seen.Add(candidateKey)) { continue; @@ -1502,7 +1532,7 @@ private sealed class SchemaObjectContext private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value) { - internal string Key => ParentPath + "\u0000" + PropertyName; + internal string Key => AppendJsonPointer(ParentPath, PropertyName); } private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); @@ -1990,7 +2020,7 @@ private static void CollectSchemaBranch( GetOrCreateSchemaContext(contexts, entry.Key).Required.UnionWith(entry.Value.Required); foreach (string property in entry.Value.Required) { - group.Add(entry.Key + "\u0000" + property); + group.Add(AppendJsonPointer(entry.Key, property)); } } @@ -2214,7 +2244,8 @@ JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.In private sealed class SourceRecoveryContext( object? value, JsonTypeInfo contract, JsonPropertyInfo? property = null, - object? owner = null, JsonTypeInfo? ownerContract = null, SourceRecoveryContext? parent = null) + object? owner = null, JsonTypeInfo? ownerContract = null, SourceRecoveryContext? parent = null, + Type? fallbackType = null) { private readonly Dictionary _children = new(StringComparer.Ordinal); private Dictionary, JsonTypeInfo>? _scopeContracts; @@ -2479,7 +2510,21 @@ private JsonTypeInfo ResolveActiveContract() if (Contract.Type == typeof(object) && Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) { - return options.GetTypeInfo(runtimeType); + try + { + return options.GetTypeInfo(runtimeType); + } + catch (NotSupportedException) when (runtimeType.IsDefined(typeof(CompilerGeneratedAttribute), false) && + typeof(IEnumerable).IsAssignableFrom(runtimeType) && fallbackType is not null) + { + JsonTypeInfo fallbackContract = options.GetTypeInfo(fallbackType); + if (fallbackContract.Kind != JsonTypeInfoKind.Enumerable) + { + throw; + } + + return fallbackContract; + } } if (Nullable.GetUnderlyingType(Contract.Type) is { } underlyingType && diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 390704112..63a5403b6 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -119,10 +119,19 @@ } """).RootElement, }); +McpServerTool iteratorTool = McpServerTool.Create( + AotTools.Iterator, + new() + { + Name = "Iterator", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -211,6 +220,12 @@ } } +var iteratorResult = await client.CallToolAsync("Iterator"); +if (iteratorResult.StructuredContent is not { } iterator || iterator[0].GetProperty("count").GetInt32() != 0) +{ + throw new Exception($"Unexpected iterator output: {iteratorResult.StructuredContent}"); +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() @@ -266,6 +281,11 @@ [McpServerToolType] internal sealed class AotTools { + internal static IEnumerable Iterator() + { + yield return default; + } + [McpServerTool(Name = "Echo", UseStructuredContent = true)] [McpAppUi(ResourceUri = "ui://aot/echo")] public static string Echo(string arg) => $"Echo: {arg}"; @@ -519,6 +539,7 @@ internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTyp [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Hashtable))] [JsonSerializable(typeof(AotAncestorRoot))] +[JsonSerializable(typeof(IEnumerable))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index b6af2963b..c53282eba 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1165,6 +1165,45 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_HandlesNotConditionalF AssertMatchesJsonSchema(outputSchema, result.StructuredContent); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverThroughFilteredExtensionCollision() + { + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(FilteredExtensionCollision)) + { + typeInfo.Properties.Single(property => property.Name == "child").ShouldSerialize = (_, _) => false; + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = resolver, + }; + McpServerTool tool = CreateStructuredOutputTestTool(new FilteredExtensionCollision(), """ + {"properties":{"child":{"required":["count"]}}} + """, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("count", out _)); + } + + private sealed class FilteredExtensionCollision + { + public FilteredExtensionChild Child { get; } = new(); + + [JsonExtensionData] + public Dictionary Extension { get; } = new() + { + ["child"] = JsonDocument.Parse("{}").RootElement, + }; + } + + private sealed class FilteredExtensionChild + { + public int Count { get; } = 42; + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRestoreCallerFilteredProperty() { @@ -1833,6 +1872,74 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesIndependentBra } } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesUnevaluatedSchemaFragments() + { + var output = new { Child = new { X = (string?)null, Y = (string?)null } }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"allOf":[ + {"properties":{"child":true},"unevaluatedProperties":{"required":["x"]}}, + {"unevaluatedProperties":{"required":["y"]}} + ]} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement child = result.GetProperty("child"); + Assert.Equal(JsonValueKind.Null, child.GetProperty("y").ValueKind); + Assert.False(child.TryGetProperty("x", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversSourceGeneratedIterator() + { + static IEnumerable Items() + { + yield return new ExplicitSchemaOutput { Id = "42" }; + } + + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(JsonContext2.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver), + }; + McpServerTool tool = CreateStructuredOutputTestTool(Items(), """ + {"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}} + """, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result[0].GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsEnclosingAssertionsForRepeatedBranches() + { + var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"type":"array","items":{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + foreach (JsonElement item in result.EnumerateArray()) + { + Assert.Equal(JsonValueKind.Null, item.GetProperty("a").ValueKind); + Assert.False(item.TryGetProperty("b", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesRequiredPathsContainingNull() + { + Dictionary output = new() + { + ["a"] = new { }, + ["a\0b"] = new { c = (string?)null }, + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"anyOf":[{"properties":{"a":{"required":["b\u0000c"]}}},{"properties":{"a\u0000b":{"required":["c"]}}}]} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("a\0b").GetProperty("c").ValueKind); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesNestedBranchRepairs() { @@ -3890,6 +3997,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(WeatherRequest))] [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))] [JsonSerializable(typeof(RecoveryConditionalOutput))] + [JsonSerializable(typeof(IEnumerable))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests ===== From 875cc0f226cd0faceb25056baad24d7e924c544e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 30 Oct 2026 00:25:10 +0800 Subject: [PATCH 20/30] Avoid recovery from reordered enumerables --- .../Server/AIFunctionMcpServerTool.cs | 37 ++----------------- .../Server/McpServerToolTests.cs | 36 +++++++++++++++++- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 91417952c..58d3a7e8b 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -2250,7 +2250,6 @@ private sealed class SourceRecoveryContext( private readonly Dictionary _children = new(StringComparer.Ordinal); private Dictionary, JsonTypeInfo>? _scopeContracts; private JsonTypeInfo? _activeContract; - private List? _items; private bool _dictionaryRead; private bool _extensionDataRead; private object? _extensionData; @@ -2342,42 +2341,14 @@ private sealed class SourceRecoveryContext( if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && serializedParent is JsonArray serializedArray && int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && - index >= 0 && index < serializedArray.Count && Value is IEnumerable enumerable) + index >= 0 && index < serializedArray.Count && Value is IList list) { - object? item; - if (Value is IList list) + if (index >= list.Count) { - if (index >= list.Count) - { - return null; - } - - item = list[index]; - } - else - { - if (_items is null) - { - _items = []; - foreach (object? element in enumerable) - { - _items.Add(element); - if (_items.Count >= serializedArray.Count) - { - break; - } - } - } - - if (index >= _items.Count) - { - return null; - } - - item = _items[index]; + return null; } - return new(item, options.GetTypeInfo(typeInfo.ElementType!), parent: this); + return new(list[index], options.GetTypeInfo(typeInfo.ElementType!), parent: this); } if (typeInfo.Kind != JsonTypeInfoKind.Object) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index c53282eba..5c3f9d4bd 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1890,7 +1890,7 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesUnevaluat } [Fact] - public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversSourceGeneratedIterator() + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverSourceGeneratedIterator() { static IEnumerable Items() { @@ -1906,7 +1906,7 @@ static IEnumerable Items() {"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}} """, options); JsonElement result = await InvokeStructuredOutputTestTool(tool); - Assert.Equal(JsonValueKind.Null, result[0].GetProperty("note").ValueKind); + Assert.False(result[0].TryGetProperty("note", out _)); } [Fact] @@ -2434,6 +2434,23 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRepeatedlyEnume Assert.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items."); } + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverReorderedEnumerable() + { + ReorderedRecoveryEnumerable source = new(); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + McpServerTool tool = CreateStructuredOutputTestTool(source, + """{"items":{"required":["count"]}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("a", result[0].GetProperty("id").GetString()); + Assert.False(result[0].TryGetProperty("count", out _)); + Assert.Equal("b", result[1].GetProperty("id").GetString()); + Assert.Equal(5, result[1].GetProperty("count").GetInt32()); + Assert.Equal(2, source.EnumerationCount); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -2734,6 +2751,21 @@ public override void Write(Utf8JsonWriter writer, RecoveryOpaqueChild value, Jso } } + private sealed class ReorderedRecoveryEnumerable : IEnumerable + { + public int EnumerationCount { get; private set; } + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + var first = new ExplicitSchemaOutputWithDefaultValue { Id = "a", Count = 0 }; + var second = new ExplicitSchemaOutputWithDefaultValue { Id = "b", Count = 5 }; + return (EnumerationCount == 1 ? new[] { first, second } : new[] { second, first }).AsEnumerable().GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + private class RecoveryCountingCollection(int count) : IReadOnlyList { public int EnumeratedItems { get; private set; } From 4bac0ebf9809ae649795c4ac3c530cf9900463cb Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 29 Oct 2026 10:06:49 -0700 Subject: [PATCH 21/30] Align NativeAOT iterator assertions with conservative recovery --- .../Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 63a5403b6..7fd0ca6e5 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -221,7 +221,11 @@ } var iteratorResult = await client.CallToolAsync("Iterator"); -if (iteratorResult.StructuredContent is not { } iterator || iterator[0].GetProperty("count").GetInt32() != 0) +if (iteratorResult.StructuredContent is not { } iterator || + iterator.ValueKind != JsonValueKind.Array || + iterator.GetArrayLength() != 1 || + iterator[0].ValueKind != JsonValueKind.Object || + iterator[0].TryGetProperty("count", out _)) { throw new Exception($"Unexpected iterator output: {iteratorResult.StructuredContent}"); } From 1bee8f7329567c2034e98e7843bf0f1e15dd766b Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 30 Oct 2026 02:49:26 +0800 Subject: [PATCH 22/30] Recover structured output from generic lists --- .../Server/AIFunctionMcpServerTool.cs | 49 ++++++++++++++-- .../Program.cs | 58 ++++++++++++++++++- .../Server/McpServerToolTests.cs | 44 ++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 58d3a7e8b..17b4af154 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Globalization; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; @@ -2341,14 +2342,17 @@ private sealed class SourceRecoveryContext( if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && serializedParent is JsonArray serializedArray && int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && - index >= 0 && index < serializedArray.Count && Value is IList list) + index >= 0 && index < serializedArray.Count) { - if (index >= list.Count) + if (Value is IList list) { - return null; + return index < list.Count ? new(list[index], options.GetTypeInfo(typeInfo.ElementType!), parent: this) : null; } - return new(list[index], options.GetTypeInfo(typeInfo.ElementType!), parent: this); + if (TryGetGenericListElement(Value, typeInfo.ElementType!, index, out object? element)) + { + return new(element, options.GetTypeInfo(typeInfo.ElementType!), parent: this); + } } if (typeInfo.Kind != JsonTypeInfoKind.Object) @@ -2422,6 +2426,43 @@ sourceContract.ExtensionData is { } extension && return null; } +#if !NETSTANDARD2_0 + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IReadOnlyList<>))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IList<>))] +#endif + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The indexed interface properties are preserved by DynamicDependency.")] + private static bool TryGetGenericListElement(object value, Type elementType, int index, out object? element) + { + element = null; + foreach (Type contract in value.GetType().GetInterfaces()) + { + if (!contract.IsGenericType || contract.GetGenericArguments()[0] != elementType) + { + continue; + } + + Type definition = contract.GetGenericTypeDefinition(); + if (definition == typeof(IList<>) || definition == typeof(IReadOnlyList<>)) + { + PropertyInfo? item = contract.GetProperty("Item"); + if (item is not null) + { + try + { + element = item.GetValue(value, [index]); + return true; + } + catch (TargetInvocationException ex) when (ex.InnerException is ArgumentOutOfRangeException or IndexOutOfRangeException) + { + return false; + } + } + } + } + + return false; + } + private object? GetExtensionData(JsonPropertyInfo property) { if (!_extensionDataRead) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 7fd0ca6e5..b1401654b 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -128,10 +128,28 @@ SerializerOptions = structuredOptions, OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement, }); +McpServerTool genericListTool = McpServerTool.Create( + () => new AotGenericList(), + new() + { + Name = "GenericList", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement, + }); +McpServerTool genericMutableListTool = McpServerTool.Create( + () => new AotGenericMutableList(), + new() + { + Name = "GenericMutableList", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = genericListTool.ProtocolTool.OutputSchema, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -230,6 +248,16 @@ throw new Exception($"Unexpected iterator output: {iteratorResult.StructuredContent}"); } +foreach (string name in new[] { "GenericList", "GenericMutableList" }) +{ + var genericListResult = await client.CallToolAsync(name); + if (genericListResult.StructuredContent is not { } genericList || + genericList[0].GetProperty("count").GetInt32() != 0) + { + throw new Exception($"Unexpected generic list output: {genericListResult.StructuredContent}"); + } +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() @@ -340,6 +368,32 @@ private static ExpandoObject CreateDynamicItems() } } +internal sealed class AotGenericList : IReadOnlyList +{ + public int Count => 1; + public AotStructuredChild this[int index] => index == 0 ? default : throw new ArgumentOutOfRangeException(nameof(index)); + public IEnumerator GetEnumerator() => ((IEnumerable)new AotStructuredChild[1]).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +internal sealed class AotGenericMutableList : IList +{ + private readonly List _items = [default]; + public AotStructuredChild this[int index] { get => _items[index]; set => throw new NotSupportedException(); } + public int Count => _items.Count; + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public int IndexOf(AotStructuredChild item) => _items.IndexOf(item); + public bool Contains(AotStructuredChild item) => _items.Contains(item); + public void CopyTo(AotStructuredChild[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public void Add(AotStructuredChild item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public void Insert(int index, AotStructuredChild item) => throw new NotSupportedException(); + public bool Remove(AotStructuredChild item) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); +} + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] internal sealed class AotNumberOutput { @@ -544,6 +598,8 @@ internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTyp [JsonSerializable(typeof(Hashtable))] [JsonSerializable(typeof(AotAncestorRoot))] [JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(AotGenericList))] +[JsonSerializable(typeof(AotGenericMutableList))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 5c3f9d4bd..a99940bf0 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -2431,9 +2431,53 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRepeatedlyEnume source, """{"items":{"required":["note"]}}"""); JsonElement result = await InvokeStructuredOutputTestTool(tool); AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(count, result.GetArrayLength()); + Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note").ValueKind)); Assert.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items."); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericListElements(bool mutable) + { + IEnumerable source = mutable ? new GenericMutableRecoveryList() : new GenericRecoveryList(); + McpServerTool tool = CreateStructuredOutputTestTool(source, + """{"items":{"required":["count"]}}""", CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(0, result[0].GetProperty("count").GetInt32()); + Assert.Equal(5, result[1].GetProperty("count").GetInt32()); + } + + private sealed class GenericMutableRecoveryList : IList + { + private readonly List _items = + [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }]; + ExplicitSchemaOutputWithDefaultValue IList.this[int index] { get => _items[index]; set => throw new NotSupportedException(); } + public int Count => _items.Count; + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + public int IndexOf(ExplicitSchemaOutputWithDefaultValue item) => _items.IndexOf(item); + public bool Contains(ExplicitSchemaOutputWithDefaultValue item) => _items.Contains(item); + public void CopyTo(ExplicitSchemaOutputWithDefaultValue[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public void Add(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public void Insert(int index, ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public bool Remove(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + } + + private sealed class GenericRecoveryList : IReadOnlyList + { + private readonly ExplicitSchemaOutputWithDefaultValue[] _items = + [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }]; + public int Count => _items.Length; + public ExplicitSchemaOutputWithDefaultValue this[int index] => _items[index]; + public IEnumerator GetEnumerator() => ((IEnumerable)_items).GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [Fact] public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverReorderedEnumerable() { From 49b1bf92fdd67be698a705dac99b9f77dd7c5eec Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 29 Oct 2026 13:18:11 -0700 Subject: [PATCH 23/30] Bound structured output recovery allocation and schema work --- .../Server/AIFunctionMcpServerTool.cs | 445 +++++++++++++++--- .../Program.cs | 24 +- .../McpServerToolConditionalRepairTests.cs | 1 + .../Server/McpServerToolRepairSearchTests.cs | 44 ++ 4 files changed, 454 insertions(+), 60 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 17b4af154..1021577df 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -844,24 +844,38 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); - if (resultNode is null) + RepairTraversalBudget budget = new(); + try { - return elementResult; - } + budget.SpendCopy(result); + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + if (resultNode is null) + { + return elementResult; + } - JsonNode? repairedResult = RepairStructuredResult( - resultNode, - schemaNode, - schema, - originalResult is null ? null : new SourceRecoveryContext( - originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object)), - fallbackType: GetDeclaredReturnType(originalResult)), - AIFunction.JsonSerializerOptions); + JsonNode? repairedResult = RepairStructuredResult( + resultNode, + schemaNode, + schema, + originalResult is null ? null : new SourceRecoveryContext( + originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object)), + fallbackType: GetDeclaredReturnType(originalResult)), + AIFunction.JsonSerializerOptions, + traversalBudget: budget); - return repairedResult is null - ? elementResult - : JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); + if (repairedResult is null) + { + return elementResult; + } + + budget.SpendCopy(repairedResult); + return JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); + } + catch (RecoveryBudgetExceededException) + { + return elementResult; + } } private Type? GetDeclaredReturnType(object result) @@ -912,11 +926,15 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) Queue fallback = new(); HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; schemaDocument ??= schemaNode; - references ??= new(schemaDocument, schema.BaseUri); traversalBudget ??= new(); + if (references is null) + { + traversalBudget.SpendCopy(schemaDocument, immutable: true); + references = new(schemaDocument, schema.BaseUri); + } // Cache predicates independently of their callers. Conditional expectations and // instance paths remain on each group and are checked on every tentative repair. - Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = new(); + Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = traversalBudget.BranchSchemas; Dictionary conjunctiveSchemas = new(StringComparer.Ordinal); List unevaluatedGroups = []; Dictionary unevaluatedCandidates = new(StringComparer.Ordinal); @@ -927,14 +945,20 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) HashSet<(string, string)> applications = []; try { - evaluationReferences ??= new( - JsonSerializer.SerializeToNode(schema.Root.Source, McpJsonUtilities.JsonContext.Default.JsonElement)!, - schema.BaseUri); + if (evaluationReferences is null) + { + traversalBudget.SpendCopy(schema.Root.Source); + evaluationReferences = new( + JsonSerializer.SerializeToNode(schema.Root.Source, McpJsonUtilities.JsonContext.Default.JsonElement)!, + schema.BaseUri); + } + traversalBudget.SpendCopy(instance); JsonElement element = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); Stack results = new(); results.Push(schema.Evaluate(element, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical })); - while (results.Count > 0 && traversalBudget.TrySpend(1)) + while (results.Count > 0) { + traversalBudget.Spend(1); EvaluationResults evaluation = results.Pop(); string path = evaluation.EvaluationPath.ToString(); // Evaluation paths retain caller provenance even when SchemaLocation is imprecise across references. @@ -947,6 +971,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) if (evaluation.Details is { } details) { + traversalBudget.Spend(details.Count); foreach (EvaluationResults detail in details) { results.Push(detail); @@ -985,10 +1010,11 @@ JsonNode FinalizeRepair(JsonNode instance) { continue; } - if (checkedKeys.Count > maxCandidateSetEvaluations || !traversalBudget.TrySpend(1)) + if (checkedKeys.Count > maxCandidateSetEvaluations) { return instance; } + traversalBudget.Spend(1); if (!unevaluatedCandidates.TryGetValue(key, out RepairCandidate? candidate)) { @@ -1021,6 +1047,7 @@ JsonNode FinalizeRepair(JsonNode instance) var schemaKey = (branch, references.GetScopeKey(scope)); if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) { + traversalBudget.SpendSchema(schemaDocument); JsonNode document = schemaDocument.DeepClone(); references.BindDynamicReferences(schemaDocument, document, scope); if (document is JsonObject documentObject) @@ -1035,6 +1062,7 @@ JsonNode FinalizeRepair(JsonNode instance) }; if (references.SupplementalSchemas.Contains(branch)) { + traversalBudget.SpendSchema(branch); branchDocument["$defs"]!["scope"] = branch.DeepClone(); } TryBuildJsonSchema(branchDocument, out branchSchema); @@ -1052,7 +1080,7 @@ bool AreConditionsValid(JsonNode instance, List conditions) if (conditionSchema is null || !TryGetNodeAtPath(instance, condition.InstancePath, out JsonNode? conditionInstance) || conditionInstance is null || - IsSchemaValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema)) != condition.Expected) + IsCandidateValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema, traversalBudget)) != condition.Expected) { return false; } @@ -1061,8 +1089,14 @@ conditionInstance is null || return true; } + bool IsCandidateValid(JsonSchema candidateSchema, JsonNode? instance) + { + traversalBudget.SpendCopy(instance); + return IsSchemaValid(candidateSchema, instance); + } + bool IsRepairValid(JsonNode instance) => - IsSchemaValid(schema, instance) && + IsCandidateValid(schema, instance) && (inheritedConditions is null || AreConditionsValid(instance, inheritedConditions)); if (IsRepairValid(resultNode)) @@ -1084,11 +1118,16 @@ bool IsRepairValid(JsonNode instance) => instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, new SchemaTraversal(traversalBudget, (subschema, path) => (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema).AbsoluteUri)), - (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsSchemaValid(compiled, value)), + (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsCandidateValid(compiled, value)), groups, references, dynamicScope); + traversalBudget.Spend(groups.Count); unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null)); if (inheritedConditions is { Count: > 0 }) { + foreach (RepairCandidateGroup group in groups) + { + traversalBudget.Spend(inheritedConditions.Count + group.Conditions.Count); + } groups = groups.Select(group => group with { Conditions = [.. inheritedConditions, .. group.Conditions], @@ -1116,8 +1155,11 @@ bool IsRepairValid(JsonNode instance) => var key = (group.InstancePath, references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty); List conjuncts = envelopes[key].Where(other => - other.Choices.SequenceEqual(group.Choices) && other.Conditions.SequenceEqual(group.Conditions) && - (other.Alternatives ?? []).SequenceEqual(group.Alternatives ?? [])).ToList(); + { + traversalBudget.Spend(1 + other.Choices.Count + other.Conditions.Count + (other.Alternatives?.Count ?? 0)); + return other.Choices.SequenceEqual(group.Choices) && other.Conditions.SequenceEqual(group.Conditions) && + (other.Alternatives ?? []).SequenceEqual(group.Alternatives ?? []); + }).ToList(); if (conjuncts.Count == 1) { qualifiedGroups.Add(group); @@ -1131,6 +1173,7 @@ bool IsRepairValid(JsonNode instance) => JsonArray clauses = []; foreach (string uri in schemaKey.Split('\n')) { + traversalBudget.SpendText(uri); clauses.Add((JsonNode)new JsonObject { ["$ref"] = uri }); } conjunction = new JsonObject { ["$id"] = "https://mcp.invalid/recovery/" + Guid.NewGuid().ToString("N"), ["allOf"] = clauses }; @@ -1141,15 +1184,27 @@ bool IsRepairValid(JsonNode instance) => qualifiedGroups.Add(group with { Schema = conjunction, - Keys = new(conjuncts.SelectMany(static item => item.Keys), StringComparer.Ordinal), - DirectKeys = new(conjuncts.SelectMany(static item => item.DirectKeys), StringComparer.Ordinal), + Keys = CombineKeys(direct: false), + DirectKeys = CombineKeys(direct: true), }); combinedEnvelopes.UnionWith(conjuncts); + + HashSet CombineKeys(bool direct) + { + HashSet keys = new(StringComparer.Ordinal); + foreach (RepairCandidateGroup conjunct in conjuncts) + { + HashSet source = direct ? conjunct.DirectKeys : conjunct.Keys; + traversalBudget.Spend(source.Count); + keys.UnionWith(source); + } + return keys; + } } groups = qualifiedGroups; List candidates = CreateRepairCandidates( - instance, originalResult, serializerOptions, contexts); + instance, originalResult, serializerOptions, contexts, traversalBudget); if (unevaluatedGroups.Count > 0) { foreach (RepairCandidate candidate in candidates) @@ -1162,21 +1217,51 @@ bool IsRepairValid(JsonNode instance) => return null; } + foreach (RepairCandidateGroup group in groups) + { + traversalBudget.Spend(group.Keys.Count); + } + traversalBudget.Spend(candidates.Count); HashSet grouped = new(groups.SelectMany(static group => group.Keys), StringComparer.Ordinal); Dictionary candidatesByKey = candidates.ToDictionary(static candidate => candidate.Key, StringComparer.Ordinal); List ungrouped = candidates.Where(candidate => !grouped.Contains(candidate.Key)).ToList(); Dictionary<(JsonNode Applicator, string Path), JsonNode> exclusiveChoices = new(); + Dictionary stableConditions = new(); + RepairMutationPaths? mutations = null; + traversalBudget.SpendCopy(instance); JsonNode combinedResult = instance.DeepClone(); bool hasCombinedRepairs = false; HashSet completedInstances = new(StringComparer.Ordinal); foreach (RepairCandidate candidate in ungrouped) { - ApplyRepairCandidate(combinedResult, candidate); + ApplyRepairCandidate(combinedResult, candidate, traversalBudget); hasCombinedRepairs = true; } foreach (RepairCandidateGroup group in groups) { + // A guard untouched by every candidate cannot be rescued by searching subsets. + bool rejected = false; + foreach (RepairCondition condition in group.Conditions) + { + if (!stableConditions.TryGetValue(condition, out bool? matches)) + { + matches = IsConditionUnaffected(condition, mutations ??= new(candidates, traversalBudget), traversalBudget) + ? AreConditionsValid(instance, [condition]) + : null; + stableConditions.Add(condition, matches); + } + if (matches == false) + { + rejected = true; + break; + } + } + if (rejected) + { + continue; + } + string enclosingPath = group.InstancePath; while (enclosingPath.Length > 0 && !completedInstances.Contains(enclosingPath)) { @@ -1215,6 +1300,7 @@ bool IsRepairValid(JsonNode instance) => List groupCandidates = []; foreach (string key in group.Keys) { + traversalBudget.Spend(1); if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) { groupCandidates.Add(candidate); @@ -1227,13 +1313,14 @@ bool IsRepairValid(JsonNode instance) => ? groupCandidates : groupCandidates.Where(candidate => candidate.ParentPath == group.InstancePath && group.DirectKeys.Contains(candidate.Key)).ToList(); + traversalBudget.SpendCopy(currentBranch); JsonNode branchResult = currentBranch.DeepClone(); foreach (RepairCandidate candidate in branchCandidates) { - ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }); + ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }, traversalBudget); } - bool branchIsValid = IsSchemaValid(branchSchema, branchResult); + bool branchIsValid = IsCandidateValid(branchSchema, branchResult); if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth && !(group.InstancePath.Length == 0 && ReferenceEquals(group.Schema, schemaNode) && references.GetScopeKey(group.DynamicScope) == @@ -1288,7 +1375,7 @@ bool IsRepairValid(JsonNode instance) => branchIsValid = ancestorSchema is not null && TryGetNodeAtPath(tentativeResult, ancestor.InstancePath, out JsonNode? ancestorInstance) && ancestorInstance is not null && - IsSchemaValid(ancestorSchema, ancestorInstance); + IsCandidateValid(ancestorSchema, ancestorInstance); } } finally @@ -1346,9 +1433,11 @@ ancestorInstance is not null && break; } + traversalBudget.Spend(ungrouped.Count); List branchCandidates = [.. ungrouped]; foreach (string key in group.Keys) { + traversalBudget.Spend(1); if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) { branchCandidates.Add(candidate); @@ -1364,6 +1453,7 @@ ancestorInstance is not null && for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++) { + traversalBudget.Spend(1); fallback.Enqueue(new(instance, [candidates[i]], depth + 1)); } @@ -1371,6 +1461,7 @@ ancestorInstance is not null && { for (int j = i + 1; j < candidates.Count && fallback.Count < maxCandidateSetEvaluations; j++) { + traversalBudget.Spend(2); fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1)); } } @@ -1385,12 +1476,14 @@ ancestorInstance is not null && for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) { RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue(); + traversalBudget.SpendCopy(attempt.Result); JsonNode candidateResult = attempt.Result.DeepClone(); foreach (RepairCandidate candidate in attempt.Candidates) { - ApplyRepairCandidate(candidateResult, candidate); + ApplyRepairCandidate(candidateResult, candidate, traversalBudget); } + traversalBudget.SpendCopy(candidateResult); if (!visited.Add(candidateResult.ToJsonString())) { continue; @@ -1477,7 +1570,8 @@ private static List CreateRepairCandidates( JsonNode resultNode, SourceRecoveryContext? originalResult, JsonSerializerOptions serializerOptions, - Dictionary contexts) + Dictionary contexts, + RepairTraversalBudget budget) { List candidates = []; HashSet seen = new(StringComparer.Ordinal); @@ -1491,6 +1585,8 @@ private static List CreateRepairCandidates( foreach (string propertyName in contextEntry.Value.Required) { + budget.SpendText(contextEntry.Key); + budget.SpendText(propertyName); if (parentObject.ContainsKey(propertyName)) { continue; @@ -1518,10 +1614,11 @@ private static List CreateRepairCandidates( return candidates; } - private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate) + private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate, RepairTraversalBudget budget) { if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject) { + budget.SpendCopy(candidate.Value); parentObject[candidate.PropertyName] = candidate.Value?.DeepClone(); } } @@ -1544,10 +1641,10 @@ private sealed record RepairExclusiveChoice( private sealed record RepairCondition( JsonNode Schema, string InstancePath, List DynamicScope, bool Expected); - private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode schema) + private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode schema, RepairTraversalBudget budget) { if (instance is not JsonObject instanceObject || schema is not JsonObject schemaObject || - schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type"))) + schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties"))) { return instance; } @@ -1561,6 +1658,7 @@ private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode sch { if (instanceObject.TryGetPropertyValue(property.Key, out JsonNode? value)) { + budget.SpendCopy(value); projection[property.Key] = value?.DeepClone(); } } @@ -1579,9 +1677,102 @@ private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode sch } } + if (schemaObject.ContainsKey("minProperties") || schemaObject.ContainsKey("maxProperties")) + { + foreach (KeyValuePair property in instanceObject) + { + budget.SpendText(property.Key); + if (!projection.ContainsKey(property.Key)) + { + projection[property.Key] = null; + } + } + } + return projection; } + private static bool IsConditionUnaffected( + RepairCondition condition, RepairMutationPaths mutations, RepairTraversalBudget budget) + { + if (condition.Schema is not JsonObject schema || + schema.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties"))) + { + return false; + } + + for (string path = condition.InstancePath; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/'))) + { + budget.SpendText(path); + if (mutations.Additions.Contains(path)) + { + return false; + } + } + + if (mutations.DirectAdditions.TryGetValue(condition.InstancePath, out HashSet? added)) + { + if (schema.ContainsKey("minProperties") || schema.ContainsKey("maxProperties")) + { + return false; + } + if (schema["required"] is JsonArray required) + { + foreach (JsonNode? item in required) + { + budget.Spend(1); + if (item?.GetValue() is string name && added.Contains(name)) + { + return false; + } + } + } + } + + if (schema["properties"] is JsonObject properties) + { + foreach (KeyValuePair property in properties) + { + budget.SpendText(property.Key); + if (mutations.AffectedAncestors.Contains(AppendJsonPointer(condition.InstancePath, property.Key))) + { + return false; + } + } + } + + return true; + } + + private sealed class RepairMutationPaths + { + internal HashSet Additions { get; } = new(StringComparer.Ordinal); + internal HashSet AffectedAncestors { get; } = new(StringComparer.Ordinal); + internal Dictionary> DirectAdditions { get; } = new(StringComparer.Ordinal); + + internal RepairMutationPaths(List candidates, RepairTraversalBudget budget) + { + foreach (RepairCandidate candidate in candidates) + { + budget.SpendText(candidate.ParentPath); + budget.SpendText(candidate.PropertyName); + string path = candidate.Key; + Additions.Add(path); + if (!DirectAdditions.TryGetValue(candidate.ParentPath, out HashSet? names)) + { + names = new(StringComparer.Ordinal); + DirectAdditions.Add(candidate.ParentPath, names); + } + names.Add(candidate.PropertyName); + for (; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/'))) + { + budget.SpendText(path); + AffectedAncestors.Add(path); + } + } + } + } + private sealed record RepairCandidateGroup( HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema, List DynamicScope, List Choices, List Conditions, @@ -1608,21 +1799,148 @@ private static void ReplaceRepairBranch(JsonNode currentBranch, string instanceP private sealed class RepairTraversalBudget { // Shared by document attempts and recursive branch repairs, including cached merges. - private int _remainingWork = 1_000_000; + // Costs include copied entries, JSON nodes, and 16-character text units. + private int _remainingWork = 2_000_000; + private int _remainingCopyWork = 1_000_000; + // Compiled branch schemas retain copies of the complete source document. + private int _remainingSchemaWork = 65_536; + private readonly Dictionary _immutableCosts = new(); + internal Dictionary<(JsonNode Schema, string Scope), JsonSchema?> BranchSchemas { get; } = new(); - internal bool TrySpend(int work) + internal void Spend(int work) { - if (work > _remainingWork) + if (work < 0 || work > _remainingWork) { - _remainingWork = 0; - return false; + throw new RecoveryBudgetExceededException(); } _remainingWork -= work; - return true; + } + + internal void SpendText(string text) => Spend(1 + text.Length / 16); + + internal void SpendContexts(Dictionary contexts) + { + Spend(contexts.Count); + foreach (KeyValuePair entry in contexts) + { + Spend(entry.Value.Required.Count); + } + } + + internal void SpendSchema(JsonNode schema) + { + int cost = GetCopyCost(schema, immutable: true); + if (cost > _remainingSchemaWork) + { + throw new RecoveryBudgetExceededException(); + } + + SpendCopyCost(cost); + _remainingSchemaWork -= cost; + } + + internal void SpendCopy(JsonNode? node, bool immutable = false) => SpendCopyCost(GetCopyCost(node, immutable)); + + internal void SpendCopy(JsonElement element) + { + int cost = 0; + Measure(element, ref cost, 0); + SpendCopyCost(cost); + } + + private void SpendCopyCost(int cost) + { + if (cost > _remainingCopyWork) + { + throw new RecoveryBudgetExceededException(); + } + _remainingCopyWork -= cost; + } + + private int GetCopyCost(JsonNode? node, bool immutable) + { + if (immutable && node is not null && _immutableCosts.TryGetValue(node, out int cached)) + { + return cached; + } + + int cost = 0; + Measure(node, ref cost, 0); + if (immutable && node is not null) + { + _immutableCosts[node] = cost; + } + return cost; + } + + private void AddCost(ref int cost, int work, int depth) + { + if (depth >= 256 || work > _remainingCopyWork - cost) + { + throw new RecoveryBudgetExceededException(); + } + cost += work; + } + + private void Measure(JsonNode? node, ref int cost, int depth) + { + AddCost(ref cost, 1, depth); + if (node is JsonObject obj) + { + foreach (KeyValuePair property in obj) + { + AddCost(ref cost, 1 + property.Key.Length / 16, depth); + Measure(property.Value, ref cost, depth + 1); + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) + { + Measure(item, ref cost, depth + 1); + } + } + else if (node is JsonValue value && value.TryGetValue(out string? text) && text is not null) + { + AddCost(ref cost, text.Length / 16, depth); + } + else if (node is JsonValue number && number.GetValueKind() == JsonValueKind.Number) + { + AddCost(ref cost, number.ToJsonString().Length / 16, depth); + } + } + + private void Measure(JsonElement element, ref int cost, int depth) + { + AddCost(ref cost, 1, depth); + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + AddCost(ref cost, 1 + property.Name.Length / 16, depth); + Measure(property.Value, ref cost, depth + 1); + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + Measure(item, ref cost, depth + 1); + } + break; + case JsonValueKind.String: + AddCost(ref cost, element.GetString()!.Length / 16, depth); + break; + case JsonValueKind.Number: + AddCost(ref cost, element.GetRawText().Length / 16, depth); + break; + } } } + private sealed class RecoveryBudgetExceededException : Exception; + private sealed class SchemaTraversal( RepairTraversalBudget budget, Func appliesUnevaluated, Func, bool> isBranchValid) @@ -1681,18 +1999,14 @@ private static void CollectSchemaContexts( } var state = (schemaNode, instancePath, references.GetScopeKey(dynamicScope)); - if (!traversal.Budget.TrySpend(1)) - { - return; - } + traversal.Budget.Spend(1); if (traversal.Completed.TryGetValue(state, out SchemaTraversalResult? completed)) { - if (traversal.Budget.TrySpend(completed.Contexts.Count + completed.Groups.Count)) - { - MergeSchemaContexts(contexts, completed.Contexts); - groups.AddRange(completed.Groups); - } + traversal.Budget.SpendContexts(completed.Contexts); + traversal.Budget.Spend(completed.Groups.Count); + MergeSchemaContexts(contexts, completed.Contexts); + groups.AddRange(completed.Groups); return; } @@ -1746,6 +2060,7 @@ private static void CollectSchemaContexts( { if (requiredProperty?.GetValue() is string propertyName) { + traversal.Budget.SpendText(propertyName); context.Required.Add(propertyName); } } @@ -1882,6 +2197,7 @@ private static void CollectSchemaContexts( { if (requiredProperty?.GetValue() is string propertyName) { + traversal.Budget.SpendText(propertyName); context.Required.Add(propertyName); } } @@ -1963,6 +2279,7 @@ private static void CollectSchemaContexts( } } + traversal.Budget.Spend(groups.Count); groups = groups.Distinct().ToList(); traversal.Active.Remove(state); // A cycle or depth cutoff produces a partial summary that cannot be reused @@ -1971,11 +2288,10 @@ private static void CollectSchemaContexts( { traversal.Completed[state] = new(contexts, groups); } - if (traversal.Budget.TrySpend(contexts.Count + groups.Count)) - { - MergeSchemaContexts(destinationContexts, contexts); - destinationGroups.AddRange(groups); - } + traversal.Budget.SpendContexts(contexts); + traversal.Budget.Spend(groups.Count); + MergeSchemaContexts(destinationContexts, contexts); + destinationGroups.AddRange(groups); } private static void MergeSchemaContexts( @@ -2009,6 +2325,7 @@ private static void CollectSchemaBranch( CollectSchemaContexts( instanceNode, branch, schemaRoot, instancePath, branchContexts, traversal, branchGroups, references, dynamicScope); + traversal.Budget.SpendContexts(branchContexts); List branchScope = SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)); List choices = exclusiveApplicator is null ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)]; @@ -2021,12 +2338,19 @@ private static void CollectSchemaBranch( GetOrCreateSchemaContext(contexts, entry.Key).Required.UnionWith(entry.Value.Required); foreach (string property in entry.Value.Required) { + traversal.Budget.SpendText(entry.Key); + traversal.Budget.SpendText(property); group.Add(AppendJsonPointer(entry.Key, property)); } } if (group.Count > 0) { + traversal.Budget.Spend(group.Count); + foreach (RepairCandidateGroup nested in branchGroups) + { + traversal.Budget.Spend(nested.Keys.Count); + } HashSet directKeys = new(group, StringComparer.Ordinal); directKeys.ExceptWith(branchGroups.SelectMany(static nested => nested.Keys)); // Restore this branch's own discriminator before composing nested selectors. @@ -2036,6 +2360,9 @@ private static void CollectSchemaBranch( foreach (RepairCandidateGroup branchGroup in branchGroups) { + traversal.Budget.Spend(1 + choices.Count + branchGroup.Choices.Count + + conditions.Count + branchGroup.Conditions.Count + + alternatives.Count + (branchGroup.Alternatives?.Count ?? 0)); groups.Add(branchGroup with { Choices = [.. choices, .. branchGroup.Choices], diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index b1401654b..749c8c3a2 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -146,10 +146,25 @@ SerializerOptions = structuredOptions, OutputSchema = genericListTool.ProtocolTool.OutputSchema, }); +JsonArray recoveryAlternatives = []; +for (int i = 0; i < 128; i++) +{ + recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)) }); +} +recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") }); +McpServerTool boundedRecoveryTool = McpServerTool.Create( + () => new AotNullableChild(), + new() + { + Name = "BoundedRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse(new JsonObject { ["anyOf"] = recoveryAlternatives }.ToJsonString()).RootElement, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -258,6 +273,13 @@ } } +var boundedRecoveryResult = await client.CallToolAsync("BoundedRecovery"); +if (boundedRecoveryResult.StructuredContent is not { } boundedRecovery || + boundedRecovery.GetRawText() != "{}") +{ + throw new Exception($"Unexpected bounded recovery output: {boundedRecoveryResult.StructuredContent}"); +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs index 6d157c727..f195eed69 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs @@ -78,6 +78,7 @@ public async Task StructuredOutput_ConditionalRepair_ComposesSiblingChoicesBefor [InlineData("""{"properties":{"tag":{"const":1}}}""")] [InlineData("""{"type":"object","properties":{"tag":{"const":1}},"required":["marker","items"]}""")] [InlineData("""{"properties":{"tag":{"const":1}},"minProperties":3}""")] + [InlineData("""{"properties":{"tag":{"const":1}},"maxProperties":3}""")] public async Task StructuredOutput_ConditionalRepair_ValidatesAncestorGuardWithoutLosingRequiredProperties(string guard) { McpServerTool tool = CreateStructuredOutputTestTool( diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs index 2ab4d790f..0b1f4d386 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -8,6 +8,50 @@ namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { +#if NET + [Theory] + [InlineData(8, true)] + [InlineData(64, false)] + [InlineData(128, false)] + [InlineData(128, true)] + public async Task StructuredOutput_Search_BoundsWideSchemaAllocations(int width, bool recoverable) + { + JsonArray alternatives = []; + for (int i = 0; i < width; i++) + { + alternatives.Add((JsonNode)new JsonObject + { + ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)), + }); + } + if (recoverable) + { + alternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") }); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new { Value = "kept", Note = (string?)null }, new JsonObject { ["anyOf"] = alternatives }.ToJsonString()); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + Task invocation = InvokeStructuredOutputTestTool(tool); + Assert.True(invocation.IsCompleted); + JsonElement result = invocation.GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for {width} branches."); + + Assert.Equal("kept", result.GetProperty("value").GetString()); + Assert.Equal(recoverable && width == 8, result.TryGetProperty("note", out _)); + if (width != 8) + { + Assert.Equal("""{"value":"kept"}""", result.GetRawText()); + } + Assert.True(allocated < 32 * 1024 * 1024, $"Recovery allocated {allocated:N0} bytes for {width} branches."); + }, TestContext.Current.CancellationToken); + } +#endif + [Theory] [InlineData("anyOf")] [InlineData("oneOf")] From c23927f3e334297a81f1ce206f24803dd9ecb763 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 29 Oct 2026 14:40:49 -0700 Subject: [PATCH 24/30] Fix structured output recovery safety and annotation boundaries --- .../Server/AIFunctionMcpServerTool.cs | 400 +++++++++++++++++- .../Program.cs | 36 +- .../Server/DelegatingToolOutputSchemaTests.cs | 54 +++ .../Server/McpServerToolRepairSearchTests.cs | 189 +++++++++ 4 files changed, 658 insertions(+), 21 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 1021577df..157fac86a 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -25,6 +25,9 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool private readonly bool? _returnsCallToolResult; private OutputSchemaCache? _outputSchema; private static readonly ConditionalWeakTable s_sourceContracts = new(); + private static readonly ConditionalWeakTable s_schemaEvaluationCosts = new(); + private static readonly TimeSpan s_recoveryRegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly JsonElement s_propertyNameInstance = JsonSerializer.SerializeToElement(string.Empty, McpJsonUtilities.JsonContext.Default.String); [ThreadStatic] private static SourceMemberSerializationState? t_serializingSourceMember; @@ -330,9 +333,7 @@ public override async ValueTask InvokeAsync( return callToolResult; } - JsonElement? recoverySchema = request.MatchedPrimitive is McpServerTool matchedTool - ? matchedTool.ProtocolTool.OutputSchema - : ProtocolTool.OutputSchema; + JsonElement? recoverySchema = GetRecoverySchema(request.MatchedPrimitive as McpServerTool); JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion, recoverySchema); return result switch { @@ -839,7 +840,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); JsonNode? schemaNode = cachedSchema.Node; JsonSchema? schema = cachedSchema.Schema; - if (schemaNode is null || schema is null || IsSchemaValid(schema, result) || HasDuplicatePropertyNames(result)) + if (schemaNode is null || schema is null) { return elementResult; } @@ -847,6 +848,10 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) RepairTraversalBudget budget = new(); try { + if (IsSchemaValid(schema, result, budget) || HasDuplicatePropertyNames(result)) + { + return elementResult; + } budget.SpendCopy(result); JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); if (resultNode is null) @@ -872,12 +877,29 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) budget.SpendCopy(repairedResult); return JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); } - catch (RecoveryBudgetExceededException) + catch (Exception exception) when (exception is RecoveryBudgetExceededException or RegexMatchTimeoutException) { return elementResult; } } + private JsonElement? GetRecoverySchema(McpServerTool? matchedTool) + { + McpServerTool schemaTool = matchedTool ?? this; + McpServerTool current = schemaTool; + while (current is DelegatingMcpServerTool delegating) + { + if (delegating.HasCustomInvocation) + { + // A transforming wrapper's schema describes its result, not the inner CLR value. + schemaTool = delegating.InnerTool; + } + current = delegating.InnerTool; + } + + return ReferenceEquals(current, this) ? schemaTool.ProtocolTool.OutputSchema : ProtocolTool.OutputSchema; + } + private Type? GetDeclaredReturnType(object result) { Type? returnType = AIFunction.UnderlyingMethod?.ReturnType; @@ -912,7 +934,8 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) SchemaReferenceResolver? references = null, List? dynamicScope = null, RepairTraversalBudget? traversalBudget = null, - List? inheritedConditions = null) + List? inheritedConditions = null, + bool includeValidAlternatives = false) { // An explicit schema may deliberately omit optional nulls. Preserve that valid output // before looking for missing required members. @@ -939,6 +962,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) List unevaluatedGroups = []; Dictionary unevaluatedCandidates = new(StringComparer.Ordinal); SchemaReferenceResolver? evaluationReferences = null; + bool skippedValidAlternatives = false; HashSet<(string Path, string Schema)> GetUnevaluatedApplications(JsonNode instance) { @@ -954,6 +978,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } traversalBudget.SpendCopy(instance); JsonElement element = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + traversalBudget.SpendEvaluation(schema, element, detailed: true); Stack results = new(); results.Push(schema.Evaluate(element, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical })); while (results.Count > 0) @@ -1092,7 +1117,7 @@ conditionInstance is null || bool IsCandidateValid(JsonSchema candidateSchema, JsonNode? instance) { traversalBudget.SpendCopy(instance); - return IsSchemaValid(candidateSchema, instance); + return IsSchemaValid(candidateSchema, instance, traversalBudget); } bool IsRepairValid(JsonNode instance) => @@ -1114,12 +1139,14 @@ bool IsRepairValid(JsonNode instance) => Dictionary contexts = new(StringComparer.Ordinal); List groups = []; HashSet<(string Path, string Schema)>? applications = null; - CollectSchemaContexts( - instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, - new SchemaTraversal(traversalBudget, (subschema, path) => + SchemaTraversal traversal = new(traversalBudget, (subschema, path) => (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema).AbsoluteUri)), - (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsCandidateValid(compiled, value)), + (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsCandidateValid(compiled, value), + includeValidAlternatives); + CollectSchemaContexts( + instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, traversal, groups, references, dynamicScope); + skippedValidAlternatives |= traversal.SkippedValidAlternatives; traversalBudget.Spend(groups.Count); unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null)); if (inheritedConditions is { Count: > 0 }) @@ -1342,7 +1369,7 @@ HashSet CombineKeys(bool direct) .Select(condition => condition with { InstancePath = condition.InstancePath.Substring(group.InstancePath.Length), - }).ToList()) is JsonNode repairedBranch) + }).ToList(), includeValidAlternatives) is JsonNode repairedBranch) { branchResult = repairedBranch; branchIsValid = true; @@ -1501,7 +1528,66 @@ ancestorInstance is not null && } } - return null; + // A valid composition can still need another branch's evaluated-property annotations. + // Prefer ordinary recovery before considering changes to already-valid choices. + return !includeValidAlternatives && skippedValidAlternatives && + s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root)).HasUnevaluated + ? RepairStructuredResult( + resultNode, schemaNode, schema, originalResult, serializerOptions, remainingDepth, + schemaDocument, references, dynamicScope, traversalBudget, inheritedConditions, + includeValidAlternatives: true) + : null; + } + + private static bool TrySetSchemaRegexTimeouts(JsonNode source, JsonSchemaNode root) + { + SchemaReferenceResolver references = new(source, root.BaseUri); + HashSet visited = [root]; + Stack pending = new(); + pending.Push(root); + List nodes = []; + while (pending.Count > 0) + { + JsonSchemaNode node = pending.Pop(); + nodes.Add(node); + foreach (KeywordData keyword in node.Keywords) + { + // Do not borrow mutable nodes or unbounded regexes from a global registry. + if (keyword.Handler.Name is "$ref" or "$dynamicRef" or "$recursiveRef" && + !references.CanResolveReference(node.BaseUri, keyword.RawValue.GetString()!)) + { + return false; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (visited.Add(child)) + { + pending.Push(child); + } + } + } + } + + foreach (JsonSchemaNode node in nodes) + { + foreach (KeywordData keyword in node.Keywords) + { + if (keyword.Handler.Name == "pattern" && keyword.Value is Regex pattern) + { + keyword.Value = new Regex(pattern.ToString(), pattern.Options, s_recoveryRegexTimeout); + } + else if (keyword.Handler.Name == "patternProperties" && keyword.Value is Dictionary patterns) + { + Dictionary bounded = new(patterns.Count, patterns.Comparer); + foreach (KeyValuePair entry in patterns) + { + bounded.Add(entry.Key, new Regex(entry.Value.ToString(), entry.Value.Options, s_recoveryRegexTimeout)); + } + keyword.Value = bounded; + } + } + } + return true; } private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? schema) @@ -1510,6 +1596,11 @@ private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? sche { JsonElement schemaElement = JsonSerializer.SerializeToElement(schemaNode, McpJsonUtilities.JsonContext.Default.JsonNode); schema = JsonSchema.Build(schemaElement, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); + if (!TrySetSchemaRegexTimeouts(schemaNode, schema.Root)) + { + schema = null; + return false; + } return true; } catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) @@ -1541,12 +1632,12 @@ private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) private sealed record OutputSchemaCache(JsonElement Source, JsonNode? Node, JsonSchema? Schema); - private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance) + private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance, RepairTraversalBudget budget) { try { JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); - return schema.Evaluate(instanceElement).IsValid; + return IsSchemaValid(schema, instanceElement, budget); } catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) { @@ -1554,11 +1645,12 @@ private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance) } } - private static bool IsSchemaValid(JsonSchema schema, JsonElement instance) + private static bool IsSchemaValid(JsonSchema schema, JsonElement instance, RepairTraversalBudget budget) { try { - return schema.Evaluate(instance).IsValid; + budget.SpendEvaluation(schema, instance); + return schema.Evaluate(instance, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid; } catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) { @@ -1798,12 +1890,14 @@ private static void ReplaceRepairBranch(JsonNode currentBranch, string instanceP private sealed class RepairTraversalBudget { + internal const int MaxEvaluationWork = 65_536; // Shared by document attempts and recursive branch repairs, including cached merges. // Costs include copied entries, JSON nodes, and 16-character text units. private int _remainingWork = 2_000_000; private int _remainingCopyWork = 1_000_000; // Compiled branch schemas retain copies of the complete source document. private int _remainingSchemaWork = 65_536; + private int _remainingEvaluationWork = MaxEvaluationWork; private readonly Dictionary _immutableCosts = new(); internal Dictionary<(JsonNode Schema, string Scope), JsonSchema?> BranchSchemas { get; } = new(); @@ -1819,6 +1913,24 @@ internal void Spend(int work) internal void SpendText(string text) => Spend(1 + text.Length / 16); + internal void SpendEvaluation(JsonSchema schema, JsonElement instance, bool detailed = false) + { + SchemaEvaluationCost evaluation = s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root)); + if (!detailed && !evaluation.HasUnevaluated) + { + return; + } + + int instanceCost = 0; + Measure(instance, ref instanceCost, 0); + int cost = evaluation.GetCost(instance, instanceCost, _remainingEvaluationWork); + if (cost > _remainingEvaluationWork) + { + throw new RecoveryBudgetExceededException(); + } + _remainingEvaluationWork -= cost; + } + internal void SpendContexts(Dictionary contexts) { Spend(contexts.Count); @@ -1941,13 +2053,250 @@ private void Measure(JsonElement element, ref int cost, int depth) private sealed class RecoveryBudgetExceededException : Exception; + private sealed class SchemaEvaluationCost + { + internal bool HasUnevaluated { get; } + private readonly JsonSchemaNode _root; + private readonly int _expandedCost; + private readonly Dictionary<(Uri Resource, string Anchor), JsonSchemaNode> _dynamicTargets = new(); + private bool _needsInstanceCost; + + internal SchemaEvaluationCost(JsonSchemaNode root) + { + _root = root; + HashSet visited = [root]; + Stack pending = new(); + pending.Push(root); + while (pending.Count > 0) + { + JsonSchemaNode node = pending.Pop(); + foreach (KeywordData keyword in node.Keywords) + { + HasUnevaluated |= keyword.Handler.Name is "unevaluatedProperties" or "unevaluatedItems"; + if (keyword.Handler.Name == "$dynamicAnchor") + { + _dynamicTargets[(node.BaseUri, keyword.RawValue.GetString()!)] = node; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (visited.Add(child)) + { + pending.Push(child); + } + } + } + } + + _expandedCost = Measure(root, new(), new(), 0); + } + + internal int GetCost(JsonElement instance, int instanceCost, int limit) + { + if (!_needsInstanceCost) + { + return _expandedCost > limit / instanceCost ? limit + 1 : _expandedCost * instanceCost; + } + + int remaining = limit; + int cost = MeasureInstance(_root, instance, string.Empty, false, [], new(), new(), 0, ref remaining); + return Math.Max(cost, limit - remaining); + } + + private int Measure( + JsonSchemaNode node, Dictionary completed, HashSet active, int depth) + { + const int limit = RepairTraversalBudget.MaxEvaluationWork + 1; + if (completed.TryGetValue(node, out int cached)) + { + return cached; + } + if (depth >= 128 || !active.Add(node)) + { + _needsInstanceCost = true; + return limit; + } + + int cost = 1; + foreach (KeywordData keyword in node.Keywords) + { + if (keyword.Handler.Name is "$defs" or "definitions") + { + continue; + } + + cost++; + if (keyword.Handler.Name is "$dynamicRef" or "$recursiveRef" && keyword.Subschemas.Length == 0) + { + _needsInstanceCost = true; + cost = limit; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + cost = Math.Min(limit, cost + Measure(child, completed, active, depth + 1)); + if (cost >= limit) + { + break; + } + } + if (cost >= limit) + { + break; + } + } + + active.Remove(node); + completed[node] = cost; + return cost; + } + + private int MeasureInstance( + JsonSchemaNode node, JsonElement instance, string path, bool propertyName, List scope, + Dictionary<(JsonSchemaNode, string, string, bool), int> completed, + HashSet<(JsonSchemaNode, string, string, bool)> active, int depth, ref int remaining) + { + const int limit = RepairTraversalBudget.MaxEvaluationWork + 1; + if (--remaining < 0 || depth >= 128) + { + return limit; + } + if (!scope.Contains(node.BaseUri)) + { + scope = [.. scope, node.BaseUri]; + } + foreach (Uri resource in scope) + { + remaining -= 1 + resource.AbsoluteUri.Length / 16; + if (remaining < 0) + { + return limit; + } + } + var state = (node, path, string.Join("\n", scope), propertyName); + if (completed.TryGetValue(state, out int cached)) + { + return cached; + } + if (!active.Add(state)) + { + return limit; + } + + int cost = 1; + foreach (KeywordData keyword in node.Keywords) + { + string name = keyword.Handler.Name; + if (name is "$defs" or "definitions") + { + continue; + } + cost++; + if (name == "$recursiveRef" && keyword.Subschemas.Length == 0) + { + // Legacy recursive scope can stop at an intervening non-recursive resource. + return limit; + } + if (name == "$dynamicRef" && keyword.Subschemas.Length == 0) + { + JsonSchemaNode? target = null; + if (Uri.TryCreate(node.BaseUri, keyword.RawValue.GetString(), out Uri? reference)) + { + string anchor = Uri.UnescapeDataString(reference.Fragment.TrimStart('#')); + foreach (Uri resource in scope) + { + if (_dynamicTargets.TryGetValue((resource, anchor), out target)) + { + break; + } + } + } + cost = target is null ? limit : + Math.Min(limit, cost + MeasureInstance(target, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining)); + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (name == "properties") + { + string property = child.RelativePath[child.RelativePath.SegmentCount - 1].ToString(); + if (instance.ValueKind == JsonValueKind.Object && instance.TryGetProperty(property, out JsonElement value)) + { + cost += MeasureInstance(child, value, AppendPath(path, property, ref remaining), false, + scope, completed, active, depth + 1, ref remaining); + } + } + else if (name is "patternProperties" or "additionalProperties" or "unevaluatedProperties" or "propertyNames") + { + if (instance.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in instance.EnumerateObject()) + { + // Property-name schemas cannot observe the property's value. + JsonElement value = name == "propertyNames" ? s_propertyNameInstance : property.Value; + cost += MeasureInstance(child, value, AppendPath(path, property.Name, ref remaining), name == "propertyNames", + scope, completed, active, depth + 1, ref remaining); + if (cost >= limit) + { + break; + } + } + } + } + else if (name is "items" or "prefixItems" or "contains" or "unevaluatedItems" or "additionalItems") + { + if (instance.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement value in instance.EnumerateArray()) + { + cost += MeasureInstance(child, value, AppendPath(path, (index++).ToString(CultureInfo.InvariantCulture), ref remaining), false, + scope, completed, active, depth + 1, ref remaining); + if (cost >= limit) + { + break; + } + } + } + } + else + { + cost += MeasureInstance(child, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining); + } + if (cost >= limit) + { + break; + } + } + if (cost >= limit) + { + cost = limit; + break; + } + } + + active.Remove(state); + completed[state] = cost; + return cost; + } + + private static string AppendPath(string path, string segment, ref int remaining) + { + remaining -= 1 + path.Length / 16 + segment.Length / 16; + if (remaining < 0) + { + throw new RecoveryBudgetExceededException(); + } + return AppendJsonPointer(path, segment); + } + } + private sealed class SchemaTraversal( RepairTraversalBudget budget, Func appliesUnevaluated, - Func, bool> isBranchValid) + Func, bool> isBranchValid, bool includeValidAlternatives = false) { internal RepairTraversalBudget Budget { get; } = budget; internal Func AppliesUnevaluated { get; } = appliesUnevaluated; internal Func, bool> IsBranchValid { get; } = isBranchValid; + internal bool IncludeValidAlternatives { get; } = includeValidAlternatives; + internal bool SkippedValidAlternatives { get; set; } internal Dictionary<(JsonNode Schema, string Path, string Scope), SchemaTraversalResult> Completed { get; } = new(); internal HashSet<(JsonNode Schema, string Path, string Scope)> Active { get; } = []; internal int IncompleteVersion { get; set; } @@ -2107,7 +2456,10 @@ private static void CollectSchemaContexts( { foreach (KeyValuePair patternProperty in patternProperties) { - if (patternProperty.Value is not null && Regex.IsMatch(property.Key, patternProperty.Key)) + traversal.Budget.SpendText(patternProperty.Key); + traversal.Budget.SpendText(property.Key); + if (patternProperty.Value is not null && + Regex.IsMatch(property.Key, patternProperty.Key, RegexOptions.None, s_recoveryRegexTimeout)) { matchedPattern = true; CollectSchemaBranch( @@ -2238,7 +2590,11 @@ private static void CollectSchemaContexts( if ((compositionKeyword == "anyOf" && validBranches > 0) || (compositionKeyword == "oneOf" && validBranches == 1)) { - continue; + if (!traversal.IncludeValidAlternatives) + { + traversal.SkippedValidAlternatives = true; + continue; + } } } @@ -3202,6 +3558,10 @@ private sealed class SchemaReferenceResolver internal HashSet SupplementalSchemas { get; } = []; internal bool HasDynamicReferences => _dynamicSchemas.Count != 0; + internal bool CanResolveReference(Uri resourceUri, string reference) => + _resources.TryGetValue(resourceUri, out JsonNode? resource) && + TryResolve(resource, reference, out _, out _); + internal SchemaReferenceResolver(JsonNode schema, Uri? rootUri = null) { _root = schema; diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 749c8c3a2..577598654 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -161,10 +161,28 @@ SerializerOptions = structuredOptions, OutputSchema = JsonDocument.Parse(new JsonObject { ["anyOf"] = recoveryAlternatives }.ToJsonString()).RootElement, }); +McpServerTool annotationRecoveryTool = McpServerTool.Create( + () => new AotNullableChild { Real = "kept" }, + new() + { + Name = "AnnotationRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"anyOf":[true,{"required":["note"],"properties":{"real":true,"note":true}}],"unevaluatedProperties":false}""").RootElement, + }); +McpServerTool regexRecoveryTool = McpServerTool.Create( + () => new AotNullableChild { Real = new string('a', 26) + "!" }, + new() + { + Name = "RegexRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"anyOf":[{"properties":{"real":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""").RootElement, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -280,6 +298,22 @@ throw new Exception($"Unexpected bounded recovery output: {boundedRecoveryResult.StructuredContent}"); } +var annotationRecoveryResult = await client.CallToolAsync("AnnotationRecovery"); +if (annotationRecoveryResult.StructuredContent is not { } annotationRecovery || + annotationRecovery.GetProperty("note").ValueKind != JsonValueKind.Null || + annotationRecovery.GetProperty("real").GetString() != "kept") +{ + throw new Exception($"Unexpected annotation recovery output: {annotationRecoveryResult.StructuredContent}"); +} + +var regexRecoveryResult = await client.CallToolAsync("RegexRecovery"); +if (regexRecoveryResult.StructuredContent is not { } regexRecovery || + regexRecovery.TryGetProperty("note", out _) || + regexRecovery.GetProperty("real").GetString() != new string('a', 26) + "!") +{ + throw new Exception($"Unexpected regex recovery output: {regexRecoveryResult.StructuredContent}"); +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index a654e9b0d..51234a5cc 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -14,6 +14,42 @@ namespace ModelContextProtocol.Tests.Server; public partial class DelegatingToolOutputSchemaTests { + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_TransformingDelegator_RecoversAgainstInnerSchema(string protocolVersion, bool overrideInnerSchema) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "transformed_note", + UseStructuredContent = true, + OutputSchema = overrideInnerSchema ? ParseJson("""{"type":"object"}""") : required, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + if (overrideInnerSchema) + { + inner = new SchemaOverrideTool(inner, required); + } + McpServerTool tool = new DelegatingTool(new PayloadTool(inner)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("payload").GetProperty("note").ValueKind); + }); + } + [Theory] [InlineData("2025-06-18", false)] [InlineData("2025-06-18", true)] @@ -951,6 +987,24 @@ private static async Task WithClientAsync(McpServerTool tool, string protocolVer private sealed class DelegatingTool(McpServerTool inner) : DelegatingMcpServerTool(inner); + private sealed class PayloadTool(McpServerTool inner) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = ParseJson($$$$"""{"type":"object","required":["payload"],"properties":{"payload":{{{{inner.ProtocolTool.OutputSchema!.Value.GetRawText()}}}}}}"""), + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + result.StructuredContent = ParseJson($$"""{"payload":{{result.StructuredContent!.Value.GetRawText()}}}"""); + return result; + } + } + private sealed class AlternateInvocationTool(McpServerTool inner, CallToolResult response) : DelegatingMcpServerTool(inner) { public override ValueTask InvokeAsync( diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs index 0b1f4d386..c436ea6c7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -3,12 +3,154 @@ using Json.Schema; using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using Moq; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Search_BoundsFiniteRecursiveAnnotationEvaluation(bool dynamicReference) + { + string reference = dynamicReference ? "\"$dynamicRef\":\"#node\"" : "\"$ref\":\"#\""; + string schema = $$$$"""{"$schema":"https://json-schema.org/draft/2020-12/schema","$dynamicAnchor":"node","required":["note"],"properties":{"child":{ {{{{reference}}}} }},"unevaluatedProperties":true}"""; + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null, Child = new { Note = (string?)null, Child = new { Note = (string?)null } } }, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("child").GetProperty("note").ValueKind); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Search_RetainsAnnotationChangingAlternatives(bool array, bool exclusive) + { + string first = exclusive + ? """{"not":{"properties":{"child":{"required":["note"]}}}}""" + : "true"; + string choice = $$$$"""{"{{{{(exclusive ? "oneOf" : "anyOf")}}}}":[{{{{first}}}},{"properties":{"child":{"required":["note"]}}}],"unevaluatedProperties":false}"""; + string schema = array ? $$"""{"items":{{choice}}}""" : choice; + object output = array + ? new[] { new { Child = new { Note = (string?)null } } } + : new { Child = new { Note = (string?)null } }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, (array ? result[0] : result).GetProperty("child").GetProperty("note").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Search_StopsAfterRegexTimeout(bool propertyPattern) + { + string input = new string('a', 26) + "!"; + object output = propertyPattern + ? new { Note = (string?)null, Items = new Dictionary { [input] = new { Value = 1 } } } + : new { Note = (string?)null, Value = input }; + string schema = propertyPattern + ? """{"required":["note"],"properties":{"items":{"patternProperties":{"^(a+)+$":false}}}}""" + : """{"anyOf":[{"properties":{"value":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await Task.Run( + () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.False(result.TryGetProperty("note", out _)); + if (propertyPattern) + { + Assert.Equal(1, result.GetProperty("items").GetProperty(input).GetProperty("value").GetInt32()); + } + else + { + Assert.Equal(input, result.GetProperty("value").GetString()); + } + } + #if NET + [Fact] + public async Task StructuredOutput_Search_BoundsLegacyRecursiveDiagnosticExpansion() + { + JsonNode output = new JsonObject { ["kept"] = 1 }; + for (int i = 0; i < 18; i++) + { + output = new JsonObject { ["child"] = output }; + } + using JsonDocument document = JsonDocument.Parse(output.ToJsonString()); + const string schema = """ + { + "$schema":"https://json-schema.org/draft/2019-09/schema", + "$id":"https://example.test/legacy/root","$recursiveAnchor":true, + "properties":{"child":{"$ref":"b"}},"unevaluatedProperties":true, + "$defs":{ + "b":{"$id":"b","properties":{"child":{"$ref":"c"}}}, + "c":{"$id":"c","$recursiveAnchor":true,"allOf":[ + {"properties":{"child":{"$recursiveRef":"#"}}}, + {"properties":{"child":{"$recursiveRef":"#"}}} + ]} + } + } + """; + McpServerTool tool = CreateStructuredOutputTestTool(document.RootElement, schema); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(document.RootElement.GetRawText(), result.GetRawText()); + Assert.True(allocated < 32 * 1024 * 1024, $"Legacy recursive diagnostics allocated {allocated:N0} bytes."); + }, TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(4, true)] + [InlineData(14, false)] + public async Task StructuredOutput_Search_BoundsUnevaluatedDagDiagnostics(int depth, bool recoverable) + { + JsonObject definitions = new() { ["s0"] = JsonNode.Parse("""{"properties":{"kept":true}}""") }; + for (int i = 1; i <= depth; i++) + { + definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}"""); + } + McpServerTool tool = CreateStructuredOutputTestTool(new { Kept = 1, Child = new { Note = (string?)null } }, + new JsonObject + { + ["$ref"] = $"#/$defs/s{depth}", + ["$defs"] = definitions, + ["unevaluatedProperties"] = JsonNode.Parse("""{"required":["note"]}"""), + }.ToJsonString()); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for diagnostic DAG depth {depth}."); + + Assert.Equal(1, result.GetProperty("kept").GetInt32()); + Assert.Equal(recoverable, result.GetProperty("child").TryGetProperty("note", out _)); + Assert.True(allocated < 32 * 1024 * 1024, $"Diagnostic evaluation allocated {allocated:N0} bytes."); + }, TestContext.Current.CancellationToken); + } + [Theory] [InlineData(8, true)] [InlineData(64, false)] @@ -317,4 +459,51 @@ public async Task StructuredOutput_Search_TraversesSharedReferenceDagOnce(int de Assert.False(result.TryGetProperty("absent", out _)); } } + + [Collection(nameof(DisableParallelization))] + public class RecoverySchemaRegistryTests + { + [Fact] + public async Task StructuredOutput_Search_DoesNotMutateBorrowedSchemaRegexes() + { + Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}"); + JsonSchema external = JsonSchema.FromText( + """{"required":["note"],"properties":{"value":{"pattern":"^safe$"}}}""", + new BuildOptions { SchemaRegistry = new() }, uri); + KeywordData pattern = external.Root.Keywords.Single(keyword => keyword.Handler.Name == "properties") + .Subschemas.Single().Keywords.Single(keyword => keyword.Handler.Name == "pattern"); + Regex original = Assert.IsType(pattern.Value); + var fetch = SchemaRegistry.Global.Fetch; + try + { + SchemaRegistry.Global.Fetch = (requested, registry) => requested == uri ? external : fetch(requested, registry); + McpServerTool tool = McpServerTool.Create(() => new { Note = (string?)null, Value = "safe" }, new() + { + Name = "tool", + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse($$"""{"$ref":"{{uri}}"}""").RootElement, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + Mock server = new(); + server.SetupGet(instance => instance.NegotiatedProtocolVersion).Returns("2026-07-28"); + RequestContext request = new(server.Object, + new JsonRpcRequest { Id = new RequestId("test"), Method = "tools/call" }, + new CallToolRequestParams { Name = "tool" }); + + CallToolResult result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal("""{"value":"safe"}""", result.StructuredContent!.Value.GetRawText()); + Assert.Same(original, pattern.Value); + } + finally + { + SchemaRegistry.Global.Fetch = fetch; + } + } + } } From 4225c52becb815f01ea95559e8e61d38b08d5ed4 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 29 Oct 2026 15:55:59 -0700 Subject: [PATCH 25/30] Preserve serializer readonly-member recovery capabilities --- .../Server/AIFunctionMcpServerTool.cs | 28 ++- .../Server/McpServerToolCreateOptions.cs | 8 + .../Program.cs | 48 +++++ .../Server/McpServerToolTests.cs | 191 ++++++++++++++++++ 4 files changed, 264 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 157fac86a..b17e51948 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -2855,23 +2855,29 @@ propertyInfo is not null && private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) { - if (propertyInfo.ShouldSerialize is not null || GetIgnoreAttribute(propertyInfo) is not null || propertyInfo.Set is not null) + if (propertyInfo.Set is not null || + (!serializerOptions.IgnoreReadOnlyProperties && !serializerOptions.IgnoreReadOnlyFields)) { return false; } - if (propertyInfo.AttributeProvider is FieldInfo) - { - return serializerOptions.IgnoreReadOnlyFields; - } + // AttributeProvider is optional and does not control serialization. Use the configured + // capability to retain readonly-member, converter, and explicitly set ShouldSerialize rules. + return !SourceMemberCapabilities.CanSerialize(propertyInfo); + } - if (propertyInfo.AttributeProvider is not PropertyInfo && serializerOptions.IgnoreReadOnlyFields) - { - return true; - } + private static class SourceMemberCapabilities + { + internal static readonly Func CanSerialize = CreateCanSerialize(); - return serializerOptions.IgnoreReadOnlyProperties && - serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary); +#if !NETSTANDARD2_0 + [DynamicDependency("get_CanSerialize", typeof(JsonPropertyInfo))] +#endif + private static Func CreateCanSerialize() => + typeof(JsonPropertyInfo).GetProperty("CanSerialize", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetMethod?.CreateDelegate(typeof(Func)) is Func canSerialize + ? canSerialize + : static _ => false; } // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index b0b6b3de7..ae094fda8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -124,8 +124,16 @@ public sealed class McpServerToolCreateOptions /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// Recovery of required members omitted by null or default-value serialization policies is best effort. + /// Recovery does not re-enumerate non-indexable collections, such as sets, queues, or linked lists. + /// Materialize these collections into arrays or lists when their elements need this recovery. + /// If safe recovery cannot produce a schema-valid result, the original serialized content is retained. + /// /// public bool UseStructuredContent { get; set; } diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 577598654..abe4c0307 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -179,10 +179,38 @@ SerializerOptions = structuredOptions, OutputSchema = JsonDocument.Parse("""{"anyOf":[{"properties":{"real":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""").RootElement, }); +List readOnlyRecoveryTools = []; +foreach (bool field in new[] { false, true }) +{ + JsonSerializerOptions readOnlyOptions = new(structuredOptions) + { + IgnoreReadOnlyProperties = field, + IgnoreReadOnlyFields = !field, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) + .WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }), + }; + readOnlyRecoveryTools.Add(McpServerTool.Create(() => new AotReadOnlyOutput(), new() + { + Name = field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery", + UseStructuredContent = true, + SerializerOptions = readOnlyOptions, + OutputSchema = JsonDocument.Parse(field + ? """{"required":["fieldNote"],"not":{"required":["note"]}}""" + : """{"required":["note"],"not":{"required":["fieldNote"]}}""").RootElement, + })); +} services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool]) + .WithTools(readOnlyRecoveryTools) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -314,6 +342,18 @@ throw new Exception($"Unexpected regex recovery output: {regexRecoveryResult.StructuredContent}"); } +foreach (bool field in new[] { false, true }) +{ + var readOnlyResult = await client.CallToolAsync(field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery"); + if (readOnlyResult.StructuredContent is not { } readOnly || + !readOnly.TryGetProperty(field ? "fieldNote" : "note", out JsonElement note) || + note.ValueKind != JsonValueKind.Null || + readOnly.TryGetProperty(field ? "note" : "fieldNote", out _)) + { + throw new Exception($"Unexpected readonly recovery output: {readOnlyResult.StructuredContent}"); + } +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() @@ -648,6 +688,13 @@ internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTyp _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!); } +internal sealed class AotReadOnlyOutput +{ + public string? Note => null; + [JsonInclude] + public readonly string? FieldNote = null; +} + [JsonSerializable(typeof(AotStructuredOutput))] [JsonSerializable(typeof(AotDerivedOutput))] [JsonSerializable(typeof(Dictionary))] @@ -656,6 +703,7 @@ internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTyp [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(AotGenericList))] [JsonSerializable(typeof(AotGenericMutableList))] +[JsonSerializable(typeof(AotReadOnlyOutput))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index a99940bf0..f4774df39 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1836,6 +1836,119 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclu Assert.False(result.TryGetProperty("count", out _)); } + public static IEnumerable ReadOnlyRecoveryInputs => + Enumerable.Range(0, 32).Select(bits => new object[] + { + (bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0, + (bits & 8) != 0, (bits & 16) != 0, + }); + + [Theory] + [MemberData(nameof(ReadOnlyRecoveryInputs))] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyMemberKindWithoutAttributes( + bool sourceGenerated, bool isField, bool ignoreProperties, bool ignoreFields, bool ignoreDefault) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions( + ignoreDefault ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull); + options.IncludeFields = true; + options.IgnoreReadOnlyProperties = ignoreProperties; + options.IgnoreReadOnlyFields = ignoreFields; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + bool excluded = isField ? ignoreFields : ignoreProperties; + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + property.Name = "renamed_" + property.Name; + if (excluded && property.Get is not null) + { + property.Get = static _ => throw new InvalidOperationException("Excluded getter was invoked."); + } + } + }); + + object output = isField ? new ReadOnlyRecoveryField() : new ReadOnlyRecoveryProperty(); + string schema = ignoreDefault + ? """{"required":["renamed_note","renamed_count"]}""" + : """{"required":["renamed_note"]}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + if (excluded) + { + Assert.Empty(result.EnumerateObject()); + } + else + { + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("renamed_note").ValueKind); + Assert.Equal(0, result.GetProperty("renamed_count").GetInt32()); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadExcludedConvertedCollection( + bool sourceGenerated, bool removeAttributes) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (removeAttributes) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool(new ReadOnlyConvertedCollectionOutput(), + """{"anyOf":[{"required":["values"]},{"required":["note"]}]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("values", out _)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinIncludedReadOnlyCollections( + bool sourceGenerated, bool isField) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IncludeFields = true; + options.IgnoreReadOnlyProperties = true; + options.IgnoreReadOnlyFields = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }); + object output = isField ? new ReadOnlyRecoveryCollectionField() : new ReadOnlyRecoveryCollectionProperty(); + McpServerTool tool = CreateStructuredOutputTestTool(output, + """{"required":["children"],"properties":{"children":{"items":{"required":["id","note"]}}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal("42", result.GetProperty("children")[0].GetProperty("id").GetString()); + Assert.Equal(JsonValueKind.Null, result.GetProperty("children")[0].GetProperty("note").ValueKind); + } + [Theory] [InlineData(5, false, false)] [InlineData(24, false, true)] @@ -2449,6 +2562,38 @@ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericListEle Assert.Equal(5, result[1].GetProperty("count").GetInt32()); } + [Theory] + [InlineData("linked", false)] + [InlineData("linked", true)] + [InlineData("queue", false)] + [InlineData("queue", true)] + [InlineData("set", false)] + [InlineData("set", true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_NonIndexedCollectionsRequireMaterialization( + string shape, bool materialize) + { + ExplicitSchemaOutput[] items = [new() { Id = "42" }]; + IEnumerable collection = shape switch + { + "linked" => new LinkedList(items), + "queue" => new Queue(items), + _ => new HashSet(items), + }; + McpServerTool tool = CreateStructuredOutputTestTool( + materialize ? collection.ToArray() : collection, + """{"type":"array","items":{"required":["id","note"]}}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("42", result[0].GetProperty("id").GetString()); + Assert.Equal(materialize, result[0].TryGetProperty("note", out JsonElement note)); + if (materialize) + { + Assert.Equal(JsonValueKind.Null, note.ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + private sealed class GenericMutableRecoveryList : IList { private readonly List _items = @@ -3590,6 +3735,52 @@ private sealed class ReadOnlyFieldOutput public readonly int Count = 0; } + private sealed class ReadOnlyRecoveryProperty + { + public string? Note => null; + public int Count => 0; + } + + private sealed class ReadOnlyRecoveryField + { + public readonly string? Note = null; + public readonly int Count = 0; + } + + private sealed class ReadOnlyConvertedCollectionOutput + { + [JsonConverter(typeof(ReadOnlyRecoveryListConverter))] + public List Values => throw new InvalidOperationException("Excluded getter was invoked."); + public string? Note { get; set; } + } + + private sealed class ReadOnlyRecoveryCollectionProperty + { + public List Children { get; } = [new() { Id = "42" }]; + } + + private sealed class ReadOnlyRecoveryCollectionField + { + public readonly List Children = [new() { Id = "42" }]; + } + + private sealed class ReadOnlyRecoveryListConverter : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => + throw new InvalidOperationException("Excluded converter was invoked."); + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, IncludeFields = true)] + [JsonSerializable(typeof(ReadOnlyRecoveryProperty))] + [JsonSerializable(typeof(ReadOnlyRecoveryField))] + [JsonSerializable(typeof(ReadOnlyConvertedCollectionOutput))] + [JsonSerializable(typeof(ReadOnlyRecoveryCollectionProperty))] + [JsonSerializable(typeof(ReadOnlyRecoveryCollectionField))] + private partial class ReadOnlyRecoveryJsonContext : JsonSerializerContext; + private sealed class ManyNestedDefaultOutputs { public NestedDefaultedStructuredChild Child0 { get; init; } From 21d1b3e77b77985da20e550367121ea984ba31cd Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 29 Oct 2026 16:46:45 -0700 Subject: [PATCH 26/30] Retain original structured output after recovery failures --- .../Server/AIFunctionMcpServerTool.cs | 65 ++++++-- .../Program.cs | 29 +++- .../McpServerToolRecoveryBoundaryTests.cs | 156 ++++++++++++++++++ 3 files changed, 234 insertions(+), 16 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index b17e51948..36a0212a5 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -837,17 +837,17 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); - JsonNode? schemaNode = cachedSchema.Node; - JsonSchema? schema = cachedSchema.Schema; - if (schemaNode is null || schema is null) - { - return elementResult; - } - - RepairTraversalBudget budget = new(); try { + OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); + JsonNode? schemaNode = cachedSchema.Node; + JsonSchema? schema = cachedSchema.Schema; + if (schemaNode is null || schema is null) + { + return elementResult; + } + + RepairTraversalBudget budget = new(); if (IsSchemaValid(schema, result, budget) || HasDuplicatePropertyNames(result)) { return elementResult; @@ -877,12 +877,47 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) budget.SpendCopy(repairedResult); return JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); } - catch (Exception exception) when (exception is RecoveryBudgetExceededException or RegexMatchTimeoutException) + catch (TargetInvocationException exception) when ( + exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner)) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); + throw; + } + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) { return elementResult; } } + private static bool IsNonFatalRecoveryFailure(Exception exception) + { + int remaining = 128; + return Inspect(exception, ref remaining); + + static bool Inspect(Exception exception, ref int remaining) + { + if (remaining-- <= 0 || + exception is OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException) + { + return false; + } + + if (exception is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + if (!Inspect(inner, ref remaining)) + { + return false; + } + } + return true; + } + + return exception.InnerException is null || Inspect(exception.InnerException, ref remaining); + } + } + private JsonElement? GetRecoverySchema(McpServerTool? matchedTool) { McpServerTool schemaTool = matchedTool ?? this; @@ -2962,7 +2997,7 @@ private sealed class SourceRecoveryContext( { child = GetChildCore(name, serializedParent); } - catch (NotSupportedException) + catch (NotSupportedException exception) when (IsNonFatalRecoveryFailure(exception)) { // Missing metadata and opaque converter contracts cannot authorize recovery. child = null; @@ -3429,13 +3464,13 @@ private static bool TrySerializeDictionaryKey(object? key, JsonTypeInfo contract name = document.RootElement.EnumerateObject().Single().Name; return true; } - catch (TargetInvocationException exception) when (exception.InnerException is - OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException) + catch (TargetInvocationException exception) when ( + exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); throw; } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) { return false; } @@ -3495,7 +3530,7 @@ private static bool TrySerializeSourceMember(SourceRecoveryContext member, out J serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); return true; } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) { return false; } diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index abe4c0307..434903353 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -179,6 +179,15 @@ SerializerOptions = structuredOptions, OutputSchema = JsonDocument.Parse("""{"anyOf":[{"properties":{"real":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""").RootElement, }); +McpServerTool callbackRecoveryTool = McpServerTool.Create( + () => new AotCallbackOutput(), + new() + { + Name = "CallbackRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"required":["note"]}""").RootElement, + }); List readOnlyRecoveryTools = []; foreach (bool field in new[] { false, true }) { @@ -209,7 +218,7 @@ services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool]) + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool, callbackRecoveryTool]) .WithTools(readOnlyRecoveryTools) .WithTools( [ @@ -354,6 +363,13 @@ } } +var callbackRecoveryResult = await client.CallToolAsync("CallbackRecovery"); +if (callbackRecoveryResult.IsError == true || + callbackRecoveryResult.StructuredContent?.GetRawText() != """{"kept":"kept"}""") +{ + throw new Exception($"Unexpected callback recovery output: {callbackRecoveryResult.StructuredContent}"); +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() @@ -695,6 +711,16 @@ internal sealed class AotReadOnlyOutput public readonly string? FieldNote = null; } +internal sealed class AotCallbackOutput : IJsonOnSerializing, IJsonOnSerialized +{ + private bool _serializing; + public string Kept { get; set; } = "kept"; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note => _serializing ? null : throw new InvalidOperationException("Getter requires callback state."); + public void OnSerializing() => _serializing = true; + public void OnSerialized() => _serializing = false; +} + [JsonSerializable(typeof(AotStructuredOutput))] [JsonSerializable(typeof(AotDerivedOutput))] [JsonSerializable(typeof(Dictionary))] @@ -704,6 +730,7 @@ internal sealed class AotReadOnlyOutput [JsonSerializable(typeof(AotGenericList))] [JsonSerializable(typeof(AotGenericMutableList))] [JsonSerializable(typeof(AotReadOnlyOutput))] +[JsonSerializable(typeof(AotCallbackOutput))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs index 33edee52a..d0f1d83eb 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -625,6 +625,133 @@ public async Task StructuredOutput_RecoveryBoundary_DoesNotRepeatOwnerCallbacksO Assert.Equal(2, output.GetterCalls); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenCallbackStateGetterFails( + bool sourceGenerated, bool predicate) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (predicate && typeInfo.Type == typeof(RecoveryBoundaryStatefulOutput)) + { + JsonPropertyInfo note = typeInfo.Properties.Single(property => property.Name == "note"); + note.Get = static _ => null; + note.ShouldSerialize = static (owner, _) => + owner is RecoveryBoundaryStatefulOutput output && output.SerializingCalls > output.SerializedCalls + ? false + : throw new InvalidOperationException("Predicate requires serialization callback state."); + } + }); + var output = new RecoveryBoundaryStatefulOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"kept":"kept"}""", result.GetRawText()); + Assert.Equal(2, output.SerializingCalls); + Assert.Equal(2, output.SerializedCalls); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenReflectedIndexerFails() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryThrowingList(new InvalidOperationException("Indexer failed.")), + """{"items":{"required":["note"]}}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("[{}]", result.GetRawText()); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalForNestedAggregateFailure() + { + Exception failure = new InvalidOperationException("Recovery failed."); + for (int i = 0; i < 32; i++) + { + failure = new AggregateException(failure); + } + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"kept":"kept"}""", result.GetRawText()); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_PropagatesUnboundedExceptionGraphs() + { + Exception failure = new InvalidOperationException("Recovery failed."); + for (int i = 0; i < 20; i++) + { + failure = new AggregateException(failure, failure); + } + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}"""); + + Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool))); + } + + [Theory] + [InlineData("cancel", false)] + [InlineData("cancel", true)] + [InlineData("memory", false)] + [InlineData("memory", true)] + [InlineData("stack", false)] + [InlineData("stack", true)] + [InlineData("access", false)] + [InlineData("access", true)] + [InlineData("aggregate", false)] + [InlineData("aggregate", true)] + [InlineData("wrapped-memory", false)] + [InlineData("wrapped-memory", true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressCancellationOrFatalGetterFailures(string kind, bool indexed) + { + Exception failure = kind switch + { + "cancel" => new OperationCanceledException(), + "memory" => new OutOfMemoryException(), + "stack" => new StackOverflowException(), + "access" => new AccessViolationException(), + "wrapped-memory" => new NotSupportedException("Wrapped fatal failure.", new OutOfMemoryException()), + _ => new AggregateException(new InvalidOperationException(), new OperationCanceledException()), + }; + object output = indexed + ? new RecoveryBoundaryThrowingList(failure) + : new RecoveryBoundaryStatefulOutput { Failure = failure }; + McpServerTool tool = CreateStructuredOutputTestTool(output, + indexed ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}"""); + + Exception? actual = await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool)); + + Assert.Same(failure, actual); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressOriginalSerializationFailure(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + var failure = new InvalidOperationException("Initial serialization failed."); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { FailDuringSerialization = true, Failure = failure }, + """{"required":["note"]}""", options); + + Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool))); + } + [Fact] public async Task StructuredOutput_RecoveryBoundary_PreservesCompatibleSourceGeneratedContracts() { @@ -880,6 +1007,34 @@ private sealed class RecoveryBoundaryCallbackOutput : IJsonOnSerializing, IJsonO public void OnSerialized() => SerializedCalls++; } + private sealed class RecoveryBoundaryStatefulOutput : IJsonOnSerializing, IJsonOnSerialized + { + private bool _serializing; + public string Kept { get; set; } = "kept"; + public string? Note => _serializing && !FailDuringSerialization ? null : throw Failure; + [JsonIgnore] + public Exception Failure { get; set; } = new InvalidOperationException("Getter requires serialization callback state."); + [JsonIgnore] + public bool FailDuringSerialization { get; set; } + [JsonIgnore] + public int SerializingCalls { get; private set; } + [JsonIgnore] + public int SerializedCalls { get; private set; } + public void OnSerializing() { _serializing = true; SerializingCalls++; } + public void OnSerialized() { _serializing = false; SerializedCalls++; } + } + + private sealed class RecoveryBoundaryThrowingList(Exception failure) : IReadOnlyList + { + public int Count => 1; + public RecoveryBoundaryNullableOutput this[int index] => throw failure; + public IEnumerator GetEnumerator() + { + yield return new(); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + private static McpServerTool CreateRecoveryBoundaryTool(object value, string schema, JsonSerializerOptions options) => McpServerTool.Create(new RecoveryBoundaryFunction(value, options), new() { @@ -900,6 +1055,7 @@ private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOption [JsonSerializable(typeof(RecoveryBoundaryConvertedOutput))] [JsonSerializable(typeof(RecoveryBoundaryReadonlyOutput))] [JsonSerializable(typeof(RecoveryBoundaryCallbackOutput))] + [JsonSerializable(typeof(RecoveryBoundaryStatefulOutput))] [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))] [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))] [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] From 67b6f7aba7a45cf8e931087ef08f0ff487056e59 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 30 Oct 2026 11:03:12 -0700 Subject: [PATCH 27/30] Preserve effective schemas through forwarding tool invocations --- .../Server/AIFunctionMcpServerTool.cs | 80 +++++- .../Server/DelegatingMcpServerTool.cs | 5 +- .../Server/McpServerImpl.cs | 32 ++- .../Server/RequestContext.cs | 7 + .../Program.cs | 45 ++++ .../Server/DelegatingToolOutputSchemaTests.cs | 246 +++++++++++++++++- 6 files changed, 395 insertions(+), 20 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 36a0212a5..6b1bedc72 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -24,6 +24,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool private readonly IReadOnlyList _metadata; private readonly bool? _returnsCallToolResult; private OutputSchemaCache? _outputSchema; + private OutputSchemaCache? _alternateOutputSchema; private static readonly ConditionalWeakTable s_sourceContracts = new(); private static readonly ConditionalWeakTable s_schemaEvaluationCosts = new(); private static readonly TimeSpan s_recoveryRegexTimeout = TimeSpan.FromMilliseconds(100); @@ -334,8 +335,16 @@ public override async ValueTask InvokeAsync( } JsonElement? recoverySchema = GetRecoverySchema(request.MatchedPrimitive as McpServerTool); - JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion, recoverySchema); - return result switch + JsonElement? effectiveSchema = request.SupportsDeferredStructuredContentRecovery + ? (request.MatchedPrimitive as McpServerTool)?.ProtocolTool.OutputSchema + : null; + bool deferOuterRecovery = effectiveSchema is not null && !Nullable.Equals(recoverySchema, effectiveSchema); + RepairTraversalBudget? sharedBudget = deferOuterRecovery ? new() : null; + string? protocolVersion = request.Server.NegotiatedProtocolVersion; + JsonElement? structuredContent = CreateStructuredResponse( + result, protocolVersion, recoverySchema, out JsonElement? originalContent, + out JsonElement? naturalContent, sharedBudget); + CallToolResult response = result switch { AIContent aiContent => new() { @@ -376,8 +385,39 @@ public override async ValueTask InvokeAsync( StructuredContent = structuredContent, }, }; + + if (deferOuterRecovery && originalContent is not null && structuredContent is not null) + { + Action recovery = CreateStructuredContentRecovery( + result, originalContent, naturalContent, structuredContent, effectiveSchema, protocolVersion, sharedBudget); + request.StructuredContentRecovery += recovery; + if (!request.SupportsDeferredStructuredContentRecovery) + { + request.StructuredContentRecovery -= recovery; + } + } + + return response; } + private Action CreateStructuredContentRecovery( + object? originalResult, JsonElement? originalContent, JsonElement? naturalContent, + JsonElement? structuredContent, JsonElement? effectiveSchema, string? protocolVersion, + RepairTraversalBudget? sharedBudget) => + finalResult => + { + // Only unchanged JSON elements prove that the wrapper retained the inner projection. + if (finalResult.IsError != true && Nullable.Equals(finalResult.StructuredContent, structuredContent)) + { + JsonElement? recovered = AddMissingRequiredNullableProperties( + originalContent, originalResult, effectiveSchema, sharedBudget); + if (!Nullable.Equals(recovered, naturalContent)) + { + finalResult.StructuredContent = ShapeStructuredResponse(recovered, protocolVersion); + } + } + }; + /// Creates a name to use based on the supplied method and naming policy. internal static string DeriveName(MethodInfo method, JsonNamingPolicy? policy = null) { @@ -778,8 +818,11 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string } private JsonElement? CreateStructuredResponse( - object? aiFunctionResult, string? negotiatedProtocolVersion, JsonElement? recoverySchema) + object? aiFunctionResult, string? negotiatedProtocolVersion, JsonElement? recoverySchema, + out JsonElement? originalContent, out JsonElement? naturalContent, RepairTraversalBudget? sharedBudget) { + originalContent = null; + naturalContent = null; if (ProtocolTool.OutputSchema is null) { // Only provide structured responses if the tool has an output schema defined. @@ -805,13 +848,19 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string break; } - elementResult = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult, recoverySchema); + originalContent = elementResult; + naturalContent = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult, recoverySchema, sharedBudget); + return ShapeStructuredResponse(naturalContent, negotiatedProtocolVersion); + } + private JsonElement? ShapeStructuredResponse(JsonElement? elementResult, string? negotiatedProtocolVersion) + { // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided // fresh per request from the stored natural schema. if (!McpSessionHandler.SupportsNaturalOutputSchemas(negotiatedProtocolVersion) && - ShouldWrapValueForLegacyWire(ProtocolTool.OutputSchema.Value)) + ProtocolTool.OutputSchema is { } outputSchema && + ShouldWrapValueForLegacyWire(outputSchema)) { JsonNode? resultNode = elementResult is { } v ? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement) @@ -828,9 +877,11 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string private JsonElement? AddMissingRequiredNullableProperties( JsonElement? elementResult, object? originalResult, - JsonElement? recoverySchema) + JsonElement? recoverySchema, + RepairTraversalBudget? sharedBudget = null) { - if (elementResult is not { } result || + if (sharedBudget?.IsRecoveryStopped == true || + elementResult is not { } result || recoverySchema is not { } outputSchema || result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { @@ -847,7 +898,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) return elementResult; } - RepairTraversalBudget budget = new(); + RepairTraversalBudget budget = sharedBudget ?? new(); if (IsSchemaValid(schema, result, budget) || HasDuplicatePropertyNames(result)) { return elementResult; @@ -885,6 +936,10 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) } catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) { + if (sharedBudget is not null) + { + sharedBudget.IsRecoveryStopped = true; + } return elementResult; } } @@ -1653,6 +1708,12 @@ private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) return cachedSchema; } + OutputSchemaCache? alternateSchema = Volatile.Read(ref _alternateOutputSchema); + if (alternateSchema is not null && alternateSchema.Source.Equals(outputSchema)) + { + return alternateSchema; + } + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); JsonSchema? schema = null; if (schemaNode is not null) @@ -1660,6 +1721,7 @@ private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) TryBuildJsonSchema(schemaNode, out schema); } + Volatile.Write(ref _alternateOutputSchema, cachedSchema); cachedSchema = new(outputSchema, schemaNode, schema); Volatile.Write(ref _outputSchema, cachedSchema); return cachedSchema; @@ -1925,6 +1987,8 @@ private static void ReplaceRepairBranch(JsonNode currentBranch, string instanceP private sealed class RepairTraversalBudget { + internal bool IsRecoveryStopped { get; set; } + internal const int MaxEvaluationWork = 65_536; // Shared by document attempts and recursive branch repairs, including cached merges. // Costs include copied entries, JSON nodes, and 16-character text units. diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs index d2bce270f..ae4d9289e 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs @@ -6,6 +6,9 @@ namespace ModelContextProtocol.Server; /// /// This is recommended as a base type when building tools that can be chained around an underlying . /// The default implementation simply passes each call to the inner tool instance. +/// When an invocation override retains the original element, +/// server dispatch can apply required-member recovery against the registered tool's effective output schema. +/// Overrides that replace or reserialize structured content are responsible for matching their advertised schema. /// public abstract class DelegatingMcpServerTool : McpServerTool { @@ -35,7 +38,7 @@ protected DelegatingMcpServerTool(McpServerTool innerTool) /// public override ValueTask InvokeAsync( - RequestContext request, + RequestContext request, CancellationToken cancellationToken = default) => _innerTool.InvokeAsync(request, cancellationToken); diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index fce7efa1f..710554034 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1603,7 +1603,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { if (request.MatchedPrimitive is McpServerTool tool) { - return tool.InvokeAsync(request, cancellationToken); + return InvokeToolAsync(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); @@ -1636,7 +1636,35 @@ private static async ValueTask> InvokeToolWith RequestContext request, CancellationToken cancellationToken) { - return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + return await InvokeToolAsync(tool, request, cancellationToken).ConfigureAwait(false); + } + + private static ValueTask InvokeToolAsync( + McpServerTool tool, + RequestContext request, + CancellationToken cancellationToken) => + tool is DelegatingMcpServerTool + ? InvokeDelegatingToolAsync(tool, request, cancellationToken) + : tool.InvokeAsync(request, cancellationToken); + + private static async ValueTask InvokeDelegatingToolAsync( + McpServerTool tool, + RequestContext request, + CancellationToken cancellationToken) + { + request.ClearStructuredContentRecovery(); + request.SupportsDeferredStructuredContentRecovery = true; + try + { + CallToolResult result = await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + request.RecoverStructuredContent(result); + return result; + } + finally + { + request.SupportsDeferredStructuredContentRecovery = false; + request.ClearStructuredContentRecovery(); + } } private McpRequestHandler> BuildComposedCallToolHandler( diff --git a/src/ModelContextProtocol.Core/Server/RequestContext.cs b/src/ModelContextProtocol.Core/Server/RequestContext.cs index 596712eea..c6aea06de 100644 --- a/src/ModelContextProtocol.Core/Server/RequestContext.cs +++ b/src/ModelContextProtocol.Core/Server/RequestContext.cs @@ -47,6 +47,13 @@ public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest) /// public IMcpServerPrimitive? MatchedPrimitive { get; set; } + internal bool SupportsDeferredStructuredContentRecovery { get; set; } + internal event Action? StructuredContentRecovery; + + internal void RecoverStructuredContent(CallToolResult result) => StructuredContentRecovery?.Invoke(result); + + internal void ClearStructuredContentRecovery() => StructuredContentRecovery = null; + /// /// Gets the JSON-RPC request associated with this context. /// diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 434903353..b7d15f023 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -215,11 +215,26 @@ : """{"required":["note"],"not":{"required":["fieldNote"]}}""").RootElement, })); } +List forwardingRecoveryTools = []; +foreach (bool required in new[] { false, true }) +{ + JsonElement requiredSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; + JsonElement optionalSchema = JsonDocument.Parse("""{"type":"object","properties":{"note":{"type":"string"}}}""").RootElement; + McpServerTool inner = McpServerTool.Create(() => new AotNullableChild(), new() + { + Name = required ? "ForwardingRequiredNote" : "ForwardingOptionalNote", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = required ? optionalSchema : requiredSchema, + }); + forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema)); +} services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool, callbackRecoveryTool]) .WithTools(readOnlyRecoveryTools) + .WithTools(forwardingRecoveryTools) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -370,6 +385,18 @@ throw new Exception($"Unexpected callback recovery output: {callbackRecoveryResult.StructuredContent}"); } +foreach (bool required in new[] { false, true }) +{ + var forwardingResult = await client.CallToolAsync(required ? "ForwardingRequiredNote" : "ForwardingOptionalNote"); + if (forwardingResult.IsError == true || + forwardingResult.StructuredContent is not { } forwarded || + forwarded.TryGetProperty("note", out JsonElement note) != required || + (required && note.ValueKind != JsonValueKind.Null)) + { + throw new Exception($"Unexpected forwarding recovery output: {forwardingResult.StructuredContent}"); + } +} + var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() @@ -735,6 +762,24 @@ internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); +internal sealed class ForwardingSchemaAotTool(McpServerTool inner, JsonElement schema) : DelegatingMcpServerTool(inner) +{ + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = schema, + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + await base.InvokeAsync(request, cancellationToken); + return result; + } +} + internal sealed class OverridingAotTool(McpServerTool inner) : ForwardingAotTool(inner) { public override ValueTask InvokeAsync( diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index 51234a5cc..294b0620e 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -50,6 +50,76 @@ await WithClientAsync(tool, protocolVersion, async client => }); } + [Theory] + [InlineData("2025-06-18", false, 0)] + [InlineData("2025-06-18", true, 0)] + [InlineData("2025-11-25", false, 0)] + [InlineData("2025-11-25", true, 0)] + [InlineData("2026-07-28", false, 0)] + [InlineData("2026-07-28", true, 0)] + [InlineData("2025-06-18", false, 1)] + [InlineData("2025-06-18", true, 1)] + [InlineData("2025-11-25", false, 1)] + [InlineData("2025-11-25", true, 1)] + [InlineData("2026-07-28", false, 1)] + [InlineData("2026-07-28", true, 1)] + [InlineData("2025-06-18", false, 2)] + [InlineData("2025-06-18", true, 2)] + [InlineData("2025-11-25", false, 2)] + [InlineData("2025-11-25", true, 2)] + [InlineData("2026-07-28", false, 2)] + [InlineData("2026-07-28", true, 2)] + [InlineData("2025-06-18", false, 3)] + [InlineData("2025-06-18", true, 3)] + [InlineData("2025-11-25", false, 3)] + [InlineData("2025-11-25", true, 3)] + [InlineData("2026-07-28", false, 3)] + [InlineData("2026-07-28", true, 3)] + [InlineData("2025-06-18", false, 4)] + [InlineData("2025-06-18", true, 4)] + [InlineData("2025-11-25", false, 4)] + [InlineData("2025-11-25", true, 4)] + [InlineData("2026-07-28", false, 4)] + [InlineData("2026-07-28", true, 4)] + [InlineData("2025-06-18", false, 5)] + [InlineData("2025-06-18", true, 5)] + [InlineData("2025-11-25", false, 5)] + [InlineData("2025-11-25", true, 5)] + [InlineData("2026-07-28", false, 5)] + [InlineData("2026-07-28", true, 5)] + public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema( + string protocolVersion, bool outerRequiresNull, int forwardingMode) + { + JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optionalSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "effective_note", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool forwarded = new DelegatingTool(inner); + if (forwardingMode != 0) + { + forwarded = new ForwardingInvocationTool(forwarded, forwardingMode); + } + McpServerTool tool = new SchemaOverrideTool(forwarded, outerRequiresNull ? requiredSchema : optionalSchema); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + Assert.Equal(outerRequiresNull, content.TryGetProperty("note", out _)); + }, alternateHandler: forwardingMode == 4); + } + [Theory] [InlineData("2025-06-18", false)] [InlineData("2025-06-18", true)] @@ -57,13 +127,13 @@ await WithClientAsync(tool, protocolVersion, async client => [InlineData("2025-11-25", true)] [InlineData("2026-07-28", false)] [InlineData("2026-07-28", true)] - public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema(string protocolVersion, bool outerRequiresNull) + public async Task ListTools_ForwardingArrayInvocation_PreservesWireShape(string protocolVersion, bool outerRequiresNull) { - JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); - JsonElement optionalSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); - McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + JsonElement requiredSchema = ParseJson("""{"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}}"""); + JsonElement optionalSchema = ParseJson("""{"type":"array","items":{"properties":{"note":{"type":"string"}}}}"""); + McpServerTool inner = McpServerTool.Create(() => new[] { new { Note = (string?)null } }, new() { - Name = "effective_note", + Name = "forwarded_array", UseStructuredContent = true, OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema, SerializerOptions = new(JsonSerializerDefaults.Web) @@ -72,15 +142,120 @@ public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema(string TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }, }); - McpServerTool tool = new SchemaOverrideTool(new DelegatingTool(inner), outerRequiresNull ? requiredSchema : optionalSchema); + McpServerTool tool = new SchemaOverrideTool( + new ForwardingInvocationTool(inner, 2), outerRequiresNull ? requiredSchema : optionalSchema); await WithClientAsync(tool, protocolVersion, async client => { var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); JsonElement content = result.StructuredContent!.Value; + Assert.NotEqual(true, result.IsError); Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); - Assert.Equal(outerRequiresNull, content.TryGetProperty("note", out _)); + if (protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + Assert.Equal(outerRequiresNull, content[0].TryGetProperty("note", out _)); + }); + } + + [Fact] + public async Task ListTools_ForwardingInvocation_RecoversAfterAsynchronousCompletion() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + McpServerTool inner = McpServerTool.Create(async () => + { + started.TrySetResult(true); + await release.Task; + return new { Note = (string?)null }; + }, new() + { + Name = "async_forwarded_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 2), requiredSchema); + await WithClientAsync(tool, "2026-07-28", async client => + { + Task pending = client.CallToolAsync(tool.ProtocolTool.Name, + cancellationToken: TestContext.Current.CancellationToken).AsTask(); + try + { + Assert.Same(started.Task, await Task.WhenAny(started.Task, pending)); + } + finally + { + release.TrySetResult(true); + } + + CallToolResult result = await pending; + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(requiredSchema).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + }); + } + + [Fact] + public async Task ListTools_ForwardingInvocation_DoesNotRestartRecoveryAfterRegexTimeout() + { + string real = new string('a', 26) + "!"; + McpServerTool inner = McpServerTool.Create(() => new { Real = real, Note = (string?)null }, new() + { + Name = "timed_out_forwarded_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","properties":{"real":{"pattern":"^(a+)+$"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), + ParseJson("""{"type":"object","required":["note"]}""")); + await WithClientAsync(tool, "2026-07-28", async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(real, result.StructuredContent!.Value.GetProperty("real").GetString()); + Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _)); + }); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_TransformingInvocation_ReceivesInnerRecoveryAndRetainsItsOutput(string protocolVersion) + { + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "transformed_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new NoteTransformingTool(inner); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal("transformed", result.StructuredContent.Value.GetProperty("note").GetString()); }); } @@ -959,15 +1134,24 @@ private static JsonElement ParseJson(string json) return document.RootElement.Clone(); } - private static async Task WithClientAsync(McpServerTool tool, string protocolVersion, Func action) + private static async Task WithClientAsync( + McpServerTool tool, string protocolVersion, Func action, bool alternateHandler = false) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); cts.CancelAfter(TestConstants.DefaultTimeout); var clientToServer = new Pipe(); var serverToClient = new Pipe(); + McpServerOptions options = new() { ToolCollection = new() { tool } }; + if (alternateHandler) + { +#pragma warning disable MCPEXP002 // Exercise registered tools through the explicit alternate handler pipeline. + options.Handlers.CallToolWithAlternateHandler = + static (_, _) => throw new InvalidOperationException("Registered tool was not matched."); +#pragma warning restore MCPEXP002 + } await using var server = McpServer.Create( new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()), - new McpServerOptions { ToolCollection = new() { tool } }); + options); Task serverTask = server.RunAsync(cts.Token); try { @@ -987,6 +1171,50 @@ private static async Task WithClientAsync(McpServerTool tool, string protocolVer private sealed class DelegatingTool(McpServerTool inner) : DelegatingMcpServerTool(inner); + private sealed class ForwardingInvocationTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner) + { + public override ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) => + mode == 1 ? base.InvokeAsync(request, cancellationToken) : ForwardAsync(request, cancellationToken); + + private async ValueTask ForwardAsync( + RequestContext request, CancellationToken cancellationToken) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + if (mode == 5) + { + await base.InvokeAsync(request, cancellationToken); + return result; + } + return mode == 2 ? result : new() + { + Content = result.Content, + StructuredContent = result.StructuredContent, + IsError = result.IsError, + Meta = result.Meta, + }; + } + } + + private sealed class NoteTransformingTool(McpServerTool inner) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""), + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind); + result.StructuredContent = ParseJson("""{"note":"transformed"}"""); + return result; + } + } + private sealed class PayloadTool(McpServerTool inner) : DelegatingMcpServerTool(inner) { public override Tool ProtocolTool { get; } = new() From 493eb4d9e5fad7f392a3b0e8b5998dde772c6b13 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 30 Oct 2026 14:52:42 -0700 Subject: [PATCH 28/30] Skip structured output recovery for serialized JSON results --- .../Server/AIFunctionMcpServerTool.cs | 6 +- .../Server/McpServerToolRepairSearchTests.cs | 187 +++++++++++++++++- 2 files changed, 186 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 6b1bedc72..62dba0959 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -334,8 +334,10 @@ public override async ValueTask InvokeAsync( return callToolResult; } - JsonElement? recoverySchema = GetRecoverySchema(request.MatchedPrimitive as McpServerTool); - JsonElement? effectiveSchema = request.SupportsDeferredStructuredContentRecovery + // These results bypass user serialization contracts in CreateStructuredResponse. + bool isSerializedJson = result is JsonElement or JsonNode; + JsonElement? recoverySchema = isSerializedJson ? null : GetRecoverySchema(request.MatchedPrimitive as McpServerTool); + JsonElement? effectiveSchema = !isSerializedJson && request.SupportsDeferredStructuredContentRecovery ? (request.MatchedPrimitive as McpServerTool)?.ProtocolTool.OutputSchema : null; bool deferOuterRecovery = effectiveSchema is not null && !Nullable.Equals(recoverySchema, effectiveSchema); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs index c436ea6c7..f2e6573e1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -8,11 +8,69 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; +using System.IO.Pipelines; +using ModelContextProtocol.Client; namespace ModelContextProtocol.Tests.Server; public partial class McpServerToolTests { + [Theory] + [InlineData("element")] + [InlineData("node")] + [InlineData("document")] + public async Task StructuredOutput_Search_RecoversClrParentWithSerializedJsonChild(string kind) + { + const string json = """{"value":42,"explicit":null}"""; + using JsonDocument document = JsonDocument.Parse(json); + object child = kind switch + { + "element" => document.RootElement, + "node" => JsonNode.Parse(json)!, + _ => document, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null, Child = child }, """{"required":["note"]}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(json, result.GetProperty("child").GetRawText()); + } + + [Fact] + public async Task StructuredOutput_Search_PreservesJsonDocumentObjectContractRecovery() + { + using JsonDocument document = JsonDocument.Parse("""{"value":42}"""); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.TypeInfoResolver = new JsonDocumentObjectContractResolver(); + McpServerTool tool = CreateStructuredOutputTestTool(document, """{"required":["note"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"note":null}""", result.GetRawText()); + } + + private sealed class JsonDocumentObjectContractResolver : DefaultJsonTypeInfoResolver + { + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + if (type != typeof(JsonDocument)) + { + return base.GetTypeInfo(type, options); + } + + JsonTypeInfo info = JsonMetadataServices.CreateObjectInfo(options, new() + { + PropertyMetadataInitializer = static _ => [], + }); + JsonPropertyInfo note = info.CreateJsonPropertyInfo(typeof(string), "note"); + note.Get = static _ => null; + info.Properties.Add(note); + return info; + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -87,12 +145,12 @@ public async Task StructuredOutput_Search_StopsAfterRegexTimeout(bool propertyPa [Fact] public async Task StructuredOutput_Search_BoundsLegacyRecursiveDiagnosticExpansion() { - JsonNode output = new JsonObject { ["kept"] = 1 }; + object output = new Dictionary { ["kept"] = 1 }; for (int i = 0; i < 18; i++) { - output = new JsonObject { ["child"] = output }; + output = new Dictionary { ["child"] = output }; } - using JsonDocument document = JsonDocument.Parse(output.ToJsonString()); + JsonElement expected = JsonSerializer.SerializeToElement(output, CreateStructuredOutputTestOptions()); const string schema = """ { "$schema":"https://json-schema.org/draft/2019-09/schema", @@ -107,7 +165,7 @@ public async Task StructuredOutput_Search_BoundsLegacyRecursiveDiagnosticExpansi } } """; - McpServerTool tool = CreateStructuredOutputTestTool(document.RootElement, schema); + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); await Task.Run(() => { @@ -115,7 +173,7 @@ await Task.Run(() => JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult(); long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(document.RootElement.GetRawText(), result.GetRawText()); + Assert.Equal(expected.GetRawText(), result.GetRawText()); Assert.True(allocated < 32 * 1024 * 1024, $"Legacy recursive diagnostics allocated {allocated:N0} bytes."); }, TestContext.Current.CancellationToken); } @@ -463,6 +521,125 @@ public async Task StructuredOutput_Search_TraversesSharedReferenceDagOnce(int de [Collection(nameof(DisableParallelization))] public class RecoverySchemaRegistryTests { + [Theory] + [InlineData("element", false)] + [InlineData("element", true)] + [InlineData("node", false)] + [InlineData("node", true)] + [InlineData("document", false)] + [InlineData("document", true)] + [InlineData("clr", false)] + [InlineData("clr", true)] + public async Task StructuredOutput_Search_DoesNotLoadSchemasForSerializedJson(string kind, bool forwarding) + { + foreach (string protocolVersion in new[] { "2025-06-18", "2025-11-25", "2026-07-28" }) + { + foreach (bool array in new[] { false, true }) + { + Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}"); + JsonSchema external = JsonSchema.FromText( + array ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}""", + new BuildOptions { SchemaRegistry = new() }, uri); + int fetches = 0; + var fetch = SchemaRegistry.Global.Fetch; + try + { + SchemaRegistry.Global.Fetch = (requested, registry) => + { + if (requested != uri) + { + return fetch(requested, registry); + } + Interlocked.Increment(ref fetches); + return external; + }; + string json = array ? """[{"value":42,"explicit":null}]""" : """{"value":42,"explicit":null}"""; + using JsonDocument document = JsonDocument.Parse(json); + var clr = new { Note = "present", Child = JsonNode.Parse(json) }; + object source = kind switch + { + "element" => document.RootElement, + "node" => JsonNode.Parse(json)!, + "document" => document, + _ => array ? new[] { clr } : (object)clr, + }; + McpServerTool inner = McpServerTool.Create(() => source, new() + { + Name = "serialized_json", + UseStructuredContent = true, + SerializerOptions = CreateStructuredOutputTestOptions(), + OutputSchema = JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","$ref":"{{uri}}"}""").RootElement, + }); + McpServerTool tool = forwarding + ? new ForwardingSchemaTool(inner, JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","allOf":[{"$ref":"{{uri}}"}]}""").RootElement) + : inner; + + CallToolResult result = await InvokeRegisteredToolAsync(tool, protocolVersion); + + Assert.NotEqual(true, result.IsError); + JsonElement content = result.StructuredContent!.Value; + if (array && protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + Assert.Equal(kind is "clr" or "document", fetches > 0); + if (kind == "clr") + { + Assert.Equal("present", (array ? content[0] : content).GetProperty("note").GetString()); + } + else + { + Assert.Equal(json, content.GetRawText()); + } + } + finally + { + SchemaRegistry.Global.Fetch = fetch; + } + } + } + } + + private static async Task InvokeRegisteredToolAsync(McpServerTool tool, string protocolVersion) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TestConstants.DefaultTimeout); + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + await using var server = McpServer.Create( + new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()), + new McpServerOptions { ToolCollection = new() { tool } }); + Task serverTask = server.RunAsync(cts.Token); + try + { + var options = TestMcpClient.CreateOptions(); + options.ProtocolVersion = protocolVersion; + await using var client = await TestMcpClient.CreateAsync( + new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()), + options, cancellationToken: cts.Token); + return await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: cts.Token); + } + finally + { + await cts.CancelAsync(); + await serverTask; + } + } + + private sealed class ForwardingSchemaTool(McpServerTool inner, JsonElement schema) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = schema, + }; + + public override ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) => + base.InvokeAsync(request, cancellationToken); + } + [Fact] public async Task StructuredOutput_Search_DoesNotMutateBorrowedSchemaRegexes() { From 64f57d1c92962a3d7172410d76a01eabe97a3075 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 30 Oct 2026 18:14:16 -0700 Subject: [PATCH 29/30] Preserve structured output across mutable forwarding boundaries --- .../Server/AIFunctionMcpServerTool.cs | 37 ++-- .../Server/McpServerImpl.cs | 4 +- .../Server/RequestContext.cs | 6 +- .../Program.cs | 89 +++++++-- .../Server/DelegatingToolOutputSchemaTests.cs | 187 ++++++++++++++++++ 5 files changed, 288 insertions(+), 35 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 62dba0959..b7ac9881a 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -336,10 +336,12 @@ public override async ValueTask InvokeAsync( // These results bypass user serialization contracts in CreateStructuredResponse. bool isSerializedJson = result is JsonElement or JsonNode; - JsonElement? recoverySchema = isSerializedJson ? null : GetRecoverySchema(request.MatchedPrimitive as McpServerTool); - JsonElement? effectiveSchema = !isSerializedJson && request.SupportsDeferredStructuredContentRecovery - ? (request.MatchedPrimitive as McpServerTool)?.ProtocolTool.OutputSchema - : null; + var recoveryTarget = request.StructuredContentRecoveryTarget; + McpServerTool? matchedTool = recoveryTarget?.Tool ?? request.MatchedPrimitive as McpServerTool; + JsonElement? matchedSchema = isSerializedJson ? null : + recoveryTarget is { } target ? target.Schema : matchedTool?.ProtocolTool.OutputSchema; + JsonElement? recoverySchema = isSerializedJson ? null : GetRecoverySchema(matchedTool, matchedSchema); + JsonElement? effectiveSchema = recoveryTarget is not null ? matchedSchema : null; bool deferOuterRecovery = effectiveSchema is not null && !Nullable.Equals(recoverySchema, effectiveSchema); RepairTraversalBudget? sharedBudget = deferOuterRecovery ? new() : null; string? protocolVersion = request.Server.NegotiatedProtocolVersion; @@ -412,7 +414,7 @@ private Action CreateStructuredContentRecovery( if (finalResult.IsError != true && Nullable.Equals(finalResult.StructuredContent, structuredContent)) { JsonElement? recovered = AddMissingRequiredNullableProperties( - originalContent, originalResult, effectiveSchema, sharedBudget); + originalContent, originalResult, effectiveSchema, sharedBudget, naturalContent); if (!Nullable.Equals(recovered, naturalContent)) { finalResult.StructuredContent = ShapeStructuredResponse(recovered, protocolVersion); @@ -880,14 +882,16 @@ ProtocolTool.OutputSchema is { } outputSchema && JsonElement? elementResult, object? originalResult, JsonElement? recoverySchema, - RepairTraversalBudget? sharedBudget = null) + RepairTraversalBudget? sharedBudget = null, + JsonElement? preferredContent = null) { + JsonElement? fallbackContent = preferredContent ?? elementResult; if (sharedBudget?.IsRecoveryStopped == true || elementResult is not { } result || recoverySchema is not { } outputSchema || result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { - return elementResult; + return fallbackContent; } try @@ -897,10 +901,15 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) JsonSchema? schema = cachedSchema.Schema; if (schemaNode is null || schema is null) { - return elementResult; + return fallbackContent; } RepairTraversalBudget budget = sharedBudget ?? new(); + // A wrapper may mutate the CLR source after serialization. Keep an already-valid projection. + if (preferredContent is { } preferred && !preferred.Equals(result) && IsSchemaValid(schema, preferred, budget)) + { + return preferred; + } if (IsSchemaValid(schema, result, budget) || HasDuplicatePropertyNames(result)) { return elementResult; @@ -909,7 +918,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); if (resultNode is null) { - return elementResult; + return fallbackContent; } JsonNode? repairedResult = RepairStructuredResult( @@ -924,7 +933,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) if (repairedResult is null) { - return elementResult; + return fallbackContent; } budget.SpendCopy(repairedResult); @@ -942,7 +951,7 @@ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) { sharedBudget.IsRecoveryStopped = true; } - return elementResult; + return fallbackContent; } } @@ -975,7 +984,7 @@ static bool Inspect(Exception exception, ref int remaining) } } - private JsonElement? GetRecoverySchema(McpServerTool? matchedTool) + private JsonElement? GetRecoverySchema(McpServerTool? matchedTool, JsonElement? matchedSchema) { McpServerTool schemaTool = matchedTool ?? this; McpServerTool current = schemaTool; @@ -989,7 +998,9 @@ static bool Inspect(Exception exception, ref int remaining) current = delegating.InnerTool; } - return ReferenceEquals(current, this) ? schemaTool.ProtocolTool.OutputSchema : ProtocolTool.OutputSchema; + return ReferenceEquals(current, this) + ? ReferenceEquals(schemaTool, matchedTool) ? matchedSchema : schemaTool.ProtocolTool.OutputSchema + : ProtocolTool.OutputSchema; } private Type? GetDeclaredReturnType(object result) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 710554034..32e21019c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1653,7 +1653,7 @@ private static async ValueTask InvokeDelegatingToolAsync( CancellationToken cancellationToken) { request.ClearStructuredContentRecovery(); - request.SupportsDeferredStructuredContentRecovery = true; + request.StructuredContentRecoveryTarget = new(tool, tool.ProtocolTool.OutputSchema); try { CallToolResult result = await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); @@ -1662,7 +1662,7 @@ private static async ValueTask InvokeDelegatingToolAsync( } finally { - request.SupportsDeferredStructuredContentRecovery = false; + request.StructuredContentRecoveryTarget = null; request.ClearStructuredContentRecovery(); } } diff --git a/src/ModelContextProtocol.Core/Server/RequestContext.cs b/src/ModelContextProtocol.Core/Server/RequestContext.cs index c6aea06de..c224b0e5c 100644 --- a/src/ModelContextProtocol.Core/Server/RequestContext.cs +++ b/src/ModelContextProtocol.Core/Server/RequestContext.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Text.Json; namespace ModelContextProtocol.Server; @@ -47,13 +48,16 @@ public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest) /// public IMcpServerPrimitive? MatchedPrimitive { get; set; } - internal bool SupportsDeferredStructuredContentRecovery { get; set; } + internal StructuredContentRecoveryState? StructuredContentRecoveryTarget { get; set; } + internal bool SupportsDeferredStructuredContentRecovery => StructuredContentRecoveryTarget is not null; internal event Action? StructuredContentRecovery; internal void RecoverStructuredContent(CallToolResult result) => StructuredContentRecovery?.Invoke(result); internal void ClearStructuredContentRecovery() => StructuredContentRecovery = null; + internal sealed record StructuredContentRecoveryState(McpServerTool Tool, JsonElement? Schema); + /// /// Gets the JSON-RPC request associated with this context. /// diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index b7d15f023..3cf96e9c5 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -218,17 +218,31 @@ List forwardingRecoveryTools = []; foreach (bool required in new[] { false, true }) { - JsonElement requiredSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; - JsonElement optionalSchema = JsonDocument.Parse("""{"type":"object","properties":{"note":{"type":"string"}}}""").RootElement; - McpServerTool inner = McpServerTool.Create(() => new AotNullableChild(), new() + foreach (bool rebindMatch in new[] { false, true }) { - Name = required ? "ForwardingRequiredNote" : "ForwardingOptionalNote", - UseStructuredContent = true, - SerializerOptions = structuredOptions, - OutputSchema = required ? optionalSchema : requiredSchema, - }); - forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema)); + JsonElement requiredSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; + JsonElement optionalSchema = JsonDocument.Parse("""{"type":"object","properties":{"note":{"type":"string"}}}""").RootElement; + McpServerTool inner = McpServerTool.Create(() => new AotNullableChild(), new() + { + Name = (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : ""), + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = required ? optionalSchema : requiredSchema, + }); + forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema, rebindMatch)); + } } +var mutableNote = new AotMutableNote(); +McpServerTool mutableNoteTool = McpServerTool.Create(() => mutableNote, new() +{ + Name = "ForwardingMutableNote", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement, +}); +forwardingRecoveryTools.Add(new ForwardingSchemaAotTool( + mutableNoteTool, JsonDocument.Parse(mutableNoteTool.ProtocolTool.OutputSchema!.Value.GetRawText()).RootElement, + afterInvocation: () => mutableNote.Note = "changed after serialization")); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() @@ -387,15 +401,28 @@ foreach (bool required in new[] { false, true }) { - var forwardingResult = await client.CallToolAsync(required ? "ForwardingRequiredNote" : "ForwardingOptionalNote"); - if (forwardingResult.IsError == true || - forwardingResult.StructuredContent is not { } forwarded || - forwarded.TryGetProperty("note", out JsonElement note) != required || - (required && note.ValueKind != JsonValueKind.Null)) + foreach (bool rebindMatch in new[] { false, true }) { - throw new Exception($"Unexpected forwarding recovery output: {forwardingResult.StructuredContent}"); + var forwardingResult = await client.CallToolAsync( + (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : "")); + if (forwardingResult.IsError == true || + forwardingResult.StructuredContent is not { } forwarded || + forwarded.TryGetProperty("note", out JsonElement note) != required || + (required && note.ValueKind != JsonValueKind.Null)) + { + throw new Exception($"Unexpected forwarding recovery output: {forwardingResult.StructuredContent}"); + } } } +var mutableNoteResult = await client.CallToolAsync("ForwardingMutableNote"); +if (mutableNoteResult.IsError == true || + mutableNoteResult.StructuredContent is not { } mutableNoteContent || + !mutableNoteContent.TryGetProperty("note", out JsonElement mutableNoteValue) || + mutableNoteValue.ValueKind != JsonValueKind.Null || + mutableNote.Note != "changed after serialization") +{ + throw new Exception($"Unexpected mutable forwarding recovery output: {mutableNoteResult.StructuredContent}"); +} var ancestorResult = await client.CallToolAsync("AncestorCycles"); JsonElement ancestor = ancestorResult.StructuredContent!.Value; @@ -586,6 +613,12 @@ internal struct AotNullableChild public string? Real { get; init; } } +internal sealed class AotMutableNote +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; set; } +} + internal sealed class AotUnevaluatedOutput { [JsonPropertyName("evaluated\0path")] @@ -758,12 +791,17 @@ internal sealed class AotCallbackOutput : IJsonOnSerializing, IJsonOnSerialized [JsonSerializable(typeof(AotGenericMutableList))] [JsonSerializable(typeof(AotReadOnlyOutput))] [JsonSerializable(typeof(AotCallbackOutput))] +[JsonSerializable(typeof(AotMutableNote))] internal partial class AotJsonContext : JsonSerializerContext; internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); -internal sealed class ForwardingSchemaAotTool(McpServerTool inner, JsonElement schema) : DelegatingMcpServerTool(inner) +internal sealed class ForwardingSchemaAotTool( + McpServerTool inner, JsonElement schema, bool rebindMatch = false, Action? afterInvocation = null) + : DelegatingMcpServerTool(inner) { + private readonly McpServerTool _inner = inner; + public override Tool ProtocolTool { get; } = new() { Name = inner.ProtocolTool.Name, @@ -774,9 +812,22 @@ internal sealed class ForwardingSchemaAotTool(McpServerTool inner, JsonElement s public override async ValueTask InvokeAsync( RequestContext request, CancellationToken cancellationToken = default) { - CallToolResult result = await base.InvokeAsync(request, cancellationToken); - await base.InvokeAsync(request, cancellationToken); - return result; + IMcpServerPrimitive? original = request.MatchedPrimitive; + if (rebindMatch) + { + request.MatchedPrimitive = _inner; + } + try + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + await base.InvokeAsync(request, cancellationToken); + afterInvocation?.Invoke(); + return result; + } + finally + { + request.MatchedPrimitive = original; + } } } diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index 294b0620e..4460103e6 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -14,6 +14,43 @@ namespace ModelContextProtocol.Tests.Server; public partial class DelegatingToolOutputSchemaTests { + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_ForwardingInvocation_CapturesSchemaBeforeUserCodeRuns(string protocolVersion) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool? tool = null; + McpServerTool inner = McpServerTool.Create(() => + { + tool!.ProtocolTool.OutputSchema = optional; + return new { Note = (string?)null }; + }, new() + { + Name = "changed_schema", + UseStructuredContent = true, + OutputSchema = optional, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(optional, tool.ProtocolTool.OutputSchema); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + }); + } + [Theory] [InlineData("2025-06-18", false)] [InlineData("2025-06-18", true)] @@ -203,6 +240,127 @@ await WithClientAsync(tool, "2026-07-28", async client => }); } + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesRegisteredSchemaWhenMatchChanges( + string protocolVersion, bool outerRequiresNull) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "rebound_note", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optional : required, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new RequestStateTool(inner, inner, null), outerRequiresNull ? required : optional); + foreach (bool alternateHandler in new[] { false, true }) + { + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(outerRequiresNull, result.StructuredContent.Value.TryGetProperty("note", out _)); + }, alternateHandler); + } + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesValidJsonAfterSourceMutation( + string protocolVersion, bool arrayResult) + { + const string itemSchema = """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""; + string schema = arrayResult ? $$"""{"type":"array","items":{{itemSchema}}}""" : itemSchema; + foreach (bool alternateHandler in new[] { false, true }) + { + var source = new MutableNote(); + McpServerTool inner = McpServerTool.Create(() => arrayResult ? (object)new[] { source } : source, new() + { + Name = "mutated_note", + UseStructuredContent = true, + OutputSchema = ParseJson(schema), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new RequestStateTool(inner, null, () => source.Note = "changed after serialization"), ParseJson(schema)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.Equal("changed after serialization", source.Note); + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + if (arrayResult) + { + if (protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + content = content[0]; + } + Assert.Equal(JsonValueKind.Null, content.GetProperty("note").ValueKind); + }, alternateHandler); + } + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesInnerProjectionWhenOuterRecoveryFails( + string protocolVersion, bool invalidSchema) + { + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "unrecoverable_outer_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","required":["note"]}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), + ParseJson(invalidSchema ? """{"type":42}""" : """{"required":["note","missing"]}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal("""{"note":null}""", result.StructuredContent!.Value.GetRawText()); + }); + } + [Fact] public async Task ListTools_ForwardingInvocation_DoesNotRestartRecoveryAfterRegexTimeout() { @@ -1171,6 +1329,35 @@ private static async Task WithClientAsync( private sealed class DelegatingTool(McpServerTool inner) : DelegatingMcpServerTool(inner); + private sealed class MutableNote + { + public string? Note { get; set; } + } + + private sealed class RequestStateTool(McpServerTool inner, McpServerTool? temporaryMatch, Action? afterInvocation) + : DelegatingMcpServerTool(inner) + { + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + IMcpServerPrimitive? original = request.MatchedPrimitive; + if (temporaryMatch is not null) + { + request.MatchedPrimitive = temporaryMatch; + } + try + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + afterInvocation?.Invoke(); + return result; + } + finally + { + request.MatchedPrimitive = original; + } + } + } + private sealed class ForwardingInvocationTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner) { public override ValueTask InvokeAsync( From 3f0015da06916c0209ff28254714234e7a22e57d Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 31 Oct 2026 12:22:01 +0800 Subject: [PATCH 30/30] Address structured output recovery review feedback --- .../Server/AIFunctionMcpServerTool.cs | 8 +++++ .../Server/McpServerImpl.cs | 13 +++++-- .../Server/DelegatingToolOutputSchemaTests.cs | 34 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index b7ac9881a..1bc0b7a2a 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -986,6 +986,14 @@ static bool Inspect(Exception exception, ref int remaining) private JsonElement? GetRecoverySchema(McpServerTool? matchedTool, JsonElement? matchedSchema) { + // A recovery target carries the schema selected for the current forwarding + // segment. Preserve it across nested delegators instead of replacing it with + // the leaf tool's contract while walking custom invocation overrides. + if (matchedSchema is not null) + { + return matchedSchema; + } + McpServerTool schemaTool = matchedTool ?? this; McpServerTool current = schemaTool; while (current is DelegatingMcpServerTool delegating) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 32e21019c..18bc2e6b3 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1652,12 +1652,21 @@ private static async ValueTask InvokeDelegatingToolAsync( RequestContext request, CancellationToken cancellationToken) { + // Custom delegators may transform the result or mutate the source object after + // invoking their inner tool. Recover against the immediate inner contract before + // those transformations so omitted values come from the original invocation. + bool hasCustomInvocation = tool is DelegatingMcpServerTool delegatingTool && delegatingTool.HasCustomInvocation; + McpServerTool recoveryTool = hasCustomInvocation ? delegatingTool!.InnerTool : tool; + request.ClearStructuredContentRecovery(); - request.StructuredContentRecoveryTarget = new(tool, tool.ProtocolTool.OutputSchema); + request.StructuredContentRecoveryTarget = new(recoveryTool, recoveryTool.ProtocolTool.OutputSchema); try { CallToolResult result = await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); - request.RecoverStructuredContent(result); + if (!hasCustomInvocation) + { + request.RecoverStructuredContent(result); + } return result; } finally diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index 4460103e6..5db556079 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -87,6 +87,40 @@ await WithClientAsync(tool, protocolVersion, async client => }); } + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_StackedTransformingDelegator_RecoversBeforeOuterTransformation(string protocolVersion) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "stacked_transformed_note", + UseStructuredContent = true, + OutputSchema = optional, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + + McpServerTool tool = new PayloadTool( + new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required)); + + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + JsonElement payload = result.StructuredContent!.Value.GetProperty("payload"); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("note").ValueKind); + Assert.True(ParseSchema(tool.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent.Value).IsValid); + }); + } + [Theory] [InlineData("2025-06-18", false, 0)] [InlineData("2025-06-18", true, 0)]