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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 351451e8921067e515b8642d39fb2d16d4252505 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 28 Oct 2026 12:28:59 +0800 Subject: [PATCH 12/12] 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) {